From b13f440f025f1f5aefddbd305a3a42cd2a0cf3bb Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 18 Jun 2026 12:36:22 -0400 Subject: [PATCH 1/8] test: add failing coverage for field access collection slug context Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../collections/Children/index.ts | 30 ++ .../collections/Parents/index.ts | 83 +++++ test/field-access-context/config.ts | 33 ++ .../globals/AccessContextGlobal.ts | 24 ++ test/field-access-context/int.spec.ts | 316 ++++++++++++++++++ test/field-access-context/shared.ts | 38 +++ test/field-access-context/tsconfig.json | 4 + 7 files changed, 528 insertions(+) create mode 100644 test/field-access-context/collections/Children/index.ts create mode 100644 test/field-access-context/collections/Parents/index.ts create mode 100644 test/field-access-context/config.ts create mode 100644 test/field-access-context/globals/AccessContextGlobal.ts create mode 100644 test/field-access-context/int.spec.ts create mode 100644 test/field-access-context/shared.ts create mode 100644 test/field-access-context/tsconfig.json diff --git a/test/field-access-context/collections/Children/index.ts b/test/field-access-context/collections/Children/index.ts new file mode 100644 index 00000000000..bbc907b0b92 --- /dev/null +++ b/test/field-access-context/collections/Children/index.ts @@ -0,0 +1,30 @@ +import type { CollectionConfig } from 'payload' + +import { childrenSlug, recordAccess } from '../../shared.js' + +export const Children: CollectionConfig = { + slug: childrenSlug, + access: { + create: () => true, + delete: () => true, + read: () => true, + update: () => true, + }, + fields: [ + { + name: 'title', + type: 'text', + }, + { + name: 'childReadProbe', + type: 'text', + access: { + read: recordAccess({ + fieldName: 'childReadProbe', + operation: 'read', + source: 'field-access', + }), + }, + }, + ], +} diff --git a/test/field-access-context/collections/Parents/index.ts b/test/field-access-context/collections/Parents/index.ts new file mode 100644 index 00000000000..9c3e9337805 --- /dev/null +++ b/test/field-access-context/collections/Parents/index.ts @@ -0,0 +1,83 @@ +import type { CollectionConfig } from 'payload' + +import { childrenSlug, parentsSlug, recordAccess } from '../../shared.js' + +export const Parents: CollectionConfig = { + slug: parentsSlug, + access: { + create: () => true, + delete: () => true, + read: () => true, + update: () => true, + }, + graphQL: { + pluralName: 'FieldAccessContextParents', + singularName: 'FieldAccessContextParent', + }, + fields: [ + { + name: 'title', + type: 'text', + }, + { + name: 'accessCreateProbe', + type: 'text', + access: { + create: recordAccess({ + fieldName: 'accessCreateProbe', + operation: 'create', + source: 'field-access', + }), + }, + }, + { + name: 'accessReadProbe', + type: 'text', + access: { + read: recordAccess({ + fieldName: 'accessReadProbe', + operation: 'read', + source: 'field-access', + }), + }, + }, + { + name: 'accessUpdateProbe', + type: 'text', + access: { + update: recordAccess({ + fieldName: 'accessUpdateProbe', + operation: 'update', + source: 'field-access', + }), + }, + }, + { + name: 'distinctProbe', + type: 'text', + access: { + read: recordAccess({ + fieldName: 'distinctProbe', + operation: 'read', + source: 'find-distinct', + }), + }, + }, + { + name: 'permissionsProbe', + type: 'text', + access: { + read: recordAccess({ + fieldName: 'permissionsProbe', + operation: 'read', + source: 'permissions', + }), + }, + }, + { + name: 'child', + relationTo: childrenSlug, + type: 'relationship', + }, + ], +} diff --git a/test/field-access-context/config.ts b/test/field-access-context/config.ts new file mode 100644 index 00000000000..766fd705bbe --- /dev/null +++ b/test/field-access-context/config.ts @@ -0,0 +1,33 @@ +import path from 'path' +import { fileURLToPath } from 'url' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { devUser } from '../credentials.js' +import { Children } from './collections/Children/index.js' +import { Parents } from './collections/Parents/index.js' +import { AccessContextGlobal } from './globals/AccessContextGlobal.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export default buildConfigWithDefaults({ + admin: { + importMap: { + baseDir: path.resolve(dirname), + }, + }, + collections: [Parents, Children], + globals: [AccessContextGlobal], + onInit: async (payload) => { + await payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }) + }, + typescript: { + outputFile: path.resolve(dirname, 'payload-types.ts'), + }, +}) diff --git a/test/field-access-context/globals/AccessContextGlobal.ts b/test/field-access-context/globals/AccessContextGlobal.ts new file mode 100644 index 00000000000..a86188c5989 --- /dev/null +++ b/test/field-access-context/globals/AccessContextGlobal.ts @@ -0,0 +1,24 @@ +import type { GlobalConfig } from 'payload' + +import { globalSlug, recordAccess } from '../shared.js' + +export const AccessContextGlobal: GlobalConfig = { + slug: globalSlug, + access: { + read: () => true, + update: () => true, + }, + fields: [ + { + name: 'globalReadProbe', + type: 'text', + access: { + read: recordAccess({ + fieldName: 'globalReadProbe', + operation: 'read', + source: 'field-access', + }), + }, + }, + ], +} diff --git a/test/field-access-context/int.spec.ts b/test/field-access-context/int.spec.ts new file mode 100644 index 00000000000..a55318ce994 --- /dev/null +++ b/test/field-access-context/int.spec.ts @@ -0,0 +1,316 @@ +import type { Payload } from 'payload' + +import path from 'path' +import { createLocalReq } from 'payload' +import { fileURLToPath } from 'url' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' + +import type { NextRESTClient } from '../__helpers/shared/NextRESTClient.js' + +import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js' +import { childrenSlug, globalSlug, parentsSlug, readAccessLog, resetAccessLog } from './shared.js' + +let payload: Payload +let restClient: NextRESTClient + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +const childIDs: (number | string)[] = [] +const parentIDs: (number | string)[] = [] + +describe('field access collection context', () => { + beforeAll(async () => { + ;({ payload, restClient } = await initPayloadInt(dirname)) + }) + + afterEach(async () => { + for (const id of parentIDs) { + await payload.delete({ collection: parentsSlug, id }) + } + parentIDs.length = 0 + + for (const id of childIDs) { + await payload.delete({ collection: childrenSlug, id }) + } + childIDs.length = 0 + + resetAccessLog() + }) + + afterAll(async () => { + await payload.destroy() + }) + + it('should pass collectionSlug to local create field access callbacks', async () => { + const doc = await payload.create({ + collection: parentsSlug, + data: { + accessCreateProbe: 'local create', + title: 'local create parent', + }, + overrideAccess: false, + }) + parentIDs.push(doc.id) + + expect(readAccessLog()).toContainEqual( + expect.objectContaining({ + collectionSlug: parentsSlug, + fieldName: 'accessCreateProbe', + operation: 'create', + source: 'field-access', + }), + ) + }) + + it('should pass collectionSlug to REST create field access callbacks', async () => { + const response = await restClient.POST(`/${parentsSlug}`, { + body: JSON.stringify({ + accessCreateProbe: 'rest create', + title: 'rest create parent', + }), + }) + const doc = await response.json() + parentIDs.push(doc.doc.id) + + expect(response.status).toBe(201) + expect(readAccessLog()).toContainEqual( + expect.objectContaining({ + collectionSlug: parentsSlug, + fieldName: 'accessCreateProbe', + operation: 'create', + source: 'field-access', + }), + ) + }) + + it('should pass collectionSlug to local read field access callbacks', async () => { + const doc = await payload.create({ + collection: parentsSlug, + data: { + accessReadProbe: 'local read', + title: 'local read parent', + }, + }) + parentIDs.push(doc.id) + resetAccessLog() + + await payload.findByID({ + id: doc.id, + collection: parentsSlug, + overrideAccess: false, + }) + + expect(readAccessLog()).toContainEqual( + expect.objectContaining({ + collectionSlug: parentsSlug, + fieldName: 'accessReadProbe', + operation: 'read', + source: 'field-access', + }), + ) + }) + + it('should pass collectionSlug to REST read field access callbacks', async () => { + const doc = await payload.create({ + collection: parentsSlug, + data: { + accessReadProbe: 'rest read', + title: 'rest read parent', + }, + }) + parentIDs.push(doc.id) + resetAccessLog() + + const response = await restClient.GET(`/${parentsSlug}/${doc.id}`) + + expect(response.status).toBe(200) + expect(readAccessLog()).toContainEqual( + expect.objectContaining({ + collectionSlug: parentsSlug, + fieldName: 'accessReadProbe', + operation: 'read', + source: 'field-access', + }), + ) + }) + + it('should pass collectionSlug to GraphQL read field access callbacks', async () => { + const doc = await payload.create({ + collection: parentsSlug, + data: { + accessReadProbe: 'graphql read', + title: 'graphql read parent', + }, + }) + parentIDs.push(doc.id) + resetAccessLog() + + const query = `query { + FieldAccessContextParents(where: { title: { equals: "graphql read parent" } }, limit: 1) { + docs { + id + accessReadProbe + } + } + }` + const response = await restClient.GRAPHQL_POST({ + body: JSON.stringify({ query }), + }) + const result = await response.json() + + expect(result.errors).toBeUndefined() + expect(readAccessLog()).toContainEqual( + expect.objectContaining({ + collectionSlug: parentsSlug, + fieldName: 'accessReadProbe', + operation: 'read', + source: 'field-access', + }), + ) + }) + + it('should pass child collectionSlug when reading populated relationship children', async () => { + const child = await payload.create({ + collection: childrenSlug, + data: { + childReadProbe: 'relationship child read', + title: 'relationship child', + }, + }) + childIDs.push(child.id) + + const parent = await payload.create({ + collection: parentsSlug, + data: { + child: child.id, + title: 'relationship parent', + }, + }) + parentIDs.push(parent.id) + resetAccessLog() + + await payload.findByID({ + id: parent.id, + collection: parentsSlug, + depth: 1, + overrideAccess: false, + }) + + expect(readAccessLog()).toContainEqual( + expect.objectContaining({ + collectionSlug: childrenSlug, + fieldName: 'childReadProbe', + operation: 'read', + source: 'field-access', + }), + ) + }) + + it('should pass collectionSlug to update field access callbacks', async () => { + const doc = await payload.create({ + collection: parentsSlug, + data: { + title: 'update parent', + }, + }) + parentIDs.push(doc.id) + resetAccessLog() + + await payload.update({ + id: doc.id, + collection: parentsSlug, + data: { + accessUpdateProbe: 'local update', + }, + overrideAccess: false, + }) + + expect(readAccessLog()).toContainEqual( + expect.objectContaining({ + collectionSlug: parentsSlug, + fieldName: 'accessUpdateProbe', + operation: 'update', + source: 'field-access', + }), + ) + }) + + it('should pass collectionSlug to findDistinct field access callbacks', async () => { + const firstDoc = await payload.create({ + collection: parentsSlug, + data: { + distinctProbe: 'one', + title: 'distinct one', + }, + }) + parentIDs.push(firstDoc.id) + + const secondDoc = await payload.create({ + collection: parentsSlug, + data: { + distinctProbe: 'two', + title: 'distinct two', + }, + }) + parentIDs.push(secondDoc.id) + resetAccessLog() + + await payload.findDistinct({ + collection: parentsSlug, + field: 'distinctProbe', + overrideAccess: false, + }) + + expect(readAccessLog()).toContainEqual( + expect.objectContaining({ + collectionSlug: parentsSlug, + fieldName: 'distinctProbe', + operation: 'read', + source: 'find-distinct', + }), + ) + }) + + it('should pass collectionSlug when building collection field permissions', async () => { + const req = await createLocalReq({}, payload) + + await payload.auth({ + headers: new Headers(), + req, + }) + + expect(readAccessLog()).toContainEqual( + expect.objectContaining({ + collectionSlug: parentsSlug, + fieldName: 'permissionsProbe', + operation: 'read', + source: 'permissions', + }), + ) + }) + + it('should leave collectionSlug undefined for global field access callbacks', async () => { + await payload.updateGlobal({ + slug: globalSlug, + data: { + globalReadProbe: 'global read', + }, + }) + resetAccessLog() + + await payload.findGlobal({ + slug: globalSlug, + overrideAccess: false, + }) + + expect(readAccessLog()).toContainEqual( + expect.objectContaining({ + collectionSlug: undefined, + fieldName: 'globalReadProbe', + operation: 'read', + source: 'field-access', + }), + ) + }) +}) diff --git a/test/field-access-context/shared.ts b/test/field-access-context/shared.ts new file mode 100644 index 00000000000..b6248d080a4 --- /dev/null +++ b/test/field-access-context/shared.ts @@ -0,0 +1,38 @@ +import type { FieldAccess } from 'payload' + +export const parentsSlug = 'field-access-context-parents' +export const childrenSlug = 'field-access-context-children' +export const globalSlug = 'field-access-context-global' + +export type AccessLogEntry = { + collectionSlug: string | undefined + fieldName: string + operation: 'create' | 'read' | 'update' + source: 'field-access' | 'find-distinct' | 'permissions' +} + +const accessLog: AccessLogEntry[] = [] + +export const pushAccessLog = (entry: AccessLogEntry): void => { + accessLog.push(entry) +} + +export const readAccessLog = (): AccessLogEntry[] => [...accessLog] + +export const resetAccessLog = (): void => { + accessLog.length = 0 +} + +export const recordAccess = ({ + fieldName, + operation, + source, +}: Pick): FieldAccess => { + return (args) => { + const { collectionSlug } = args as { collectionSlug?: string } & typeof args + + pushAccessLog({ collectionSlug, fieldName, operation, source }) + + return true + } +} diff --git a/test/field-access-context/tsconfig.json b/test/field-access-context/tsconfig.json new file mode 100644 index 00000000000..b29da4479f9 --- /dev/null +++ b/test/field-access-context/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*.ts", "./**/*.tsx"] +} From 5410013c826e59ba56c46748ecfa144ff58afc2c Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 18 Jun 2026 12:39:11 -0400 Subject: [PATCH 2/8] feat(payload): add collectionSlug to FieldAccessArgs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/payload/src/fields/config/types.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 67c1f471113..e006621408a 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -239,6 +239,11 @@ export type FieldAccessArgs * The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`. */ blockData?: JsonObject | undefined + /** + * Slug of the collection that owns the field being evaluated. + * Undefined when the field belongs to a global. + */ + collectionSlug?: string /** * The incoming, top-level document data used to `create` or `update` the document with. */ From 059ffc9afdf3dfd9583860261edd4688136be946 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 18 Jun 2026 14:45:38 -0400 Subject: [PATCH 3/8] fix(payload): pass owning collection slug to field access callbacks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/payload/src/collections/operations/findDistinct.ts | 5 ++++- packages/payload/src/fields/hooks/afterRead/promise.ts | 1 + packages/payload/src/fields/hooks/beforeValidate/promise.ts | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/payload/src/collections/operations/findDistinct.ts b/packages/payload/src/collections/operations/findDistinct.ts index 0e63f0d5de5..3d8b0ca7925 100644 --- a/packages/payload/src/collections/operations/findDistinct.ts +++ b/packages/payload/src/collections/operations/findDistinct.ts @@ -131,7 +131,10 @@ export const findDistinctOperation = async ( } if (fieldResult.field.access?.read) { - const hasAccess = await fieldResult.field.access.read({ req }) + const hasAccess = await fieldResult.field.access.read({ + collectionSlug: collectionConfig.slug, + req, + }) if (!hasAccess) { throw new Forbidden(req.t) } diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index 1665f739405..b8baffb5f7b 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -377,6 +377,7 @@ export const promise = async ({ : await field.access.read({ id: doc.id as number | string, blockData, + collectionSlug: collection?.slug, data: doc, doc, req, diff --git a/packages/payload/src/fields/hooks/beforeValidate/promise.ts b/packages/payload/src/fields/hooks/beforeValidate/promise.ts index 28b2c8e587e..baee30cb3eb 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/promise.ts @@ -323,6 +323,7 @@ export const promise = async ({ : await field.access[operation]({ id, blockData, + collectionSlug: collection?.slug, data: data as Partial, doc, req, From 032c2705e6b4f641644927f9706a1be73be2a0b4 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 18 Jun 2026 14:46:59 -0400 Subject: [PATCH 4/8] fix(payload): thread collection slug through field permissions access traversal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../getEntityPermissions/getEntityPermissions.ts | 1 + .../getEntityPermissions/populateFieldPermissions.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts index e4bf4476051..d0d8abd6779 100644 --- a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -209,6 +209,7 @@ export async function getEntityPermissions same parentPermissionsObject populateFieldPermissions({ id, + collectionSlug, data, fields: field.fields, operations, @@ -289,6 +299,7 @@ export const populateFieldPermissions = ({ populateFieldPermissions({ id, blockReferencesPermissions, + collectionSlug, data, fields: tab.fields, operations, @@ -301,6 +312,7 @@ export const populateFieldPermissions = ({ // Tab does not have a name => same parentPermissionsObject populateFieldPermissions({ id, + collectionSlug, data, fields: tab.fields, operations, From ff9b7d82c5c9b089bebfc523a2b86b7f30f1f57a Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 18 Jun 2026 14:56:50 -0400 Subject: [PATCH 5/8] test: strengthen field access collection slug coverage with negative assertions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/field-access-context/int.spec.ts | 131 +++++++++++++------------- test/field-access-context/shared.ts | 4 +- 2 files changed, 66 insertions(+), 69 deletions(-) diff --git a/test/field-access-context/int.spec.ts b/test/field-access-context/int.spec.ts index a55318ce994..80e87e2b49c 100644 --- a/test/field-access-context/int.spec.ts +++ b/test/field-access-context/int.spec.ts @@ -26,12 +26,12 @@ describe('field access collection context', () => { afterEach(async () => { for (const id of parentIDs) { - await payload.delete({ collection: parentsSlug, id }) + await payload.delete({ id, collection: parentsSlug }) } parentIDs.length = 0 for (const id of childIDs) { - await payload.delete({ collection: childrenSlug, id }) + await payload.delete({ id, collection: childrenSlug }) } childIDs.length = 0 @@ -53,14 +53,13 @@ describe('field access collection context', () => { }) parentIDs.push(doc.id) - expect(readAccessLog()).toContainEqual( - expect.objectContaining({ - collectionSlug: parentsSlug, - fieldName: 'accessCreateProbe', - operation: 'create', - source: 'field-access', - }), + const log = readAccessLog() + expect(log).toContainEqual( + expect.objectContaining({ collectionSlug: parentsSlug, fieldName: 'accessCreateProbe' }), ) + expect( + log.filter((e) => e.fieldName === 'accessCreateProbe' && e.collectionSlug === undefined), + ).toHaveLength(0) }) it('should pass collectionSlug to REST create field access callbacks', async () => { @@ -74,14 +73,13 @@ describe('field access collection context', () => { parentIDs.push(doc.doc.id) expect(response.status).toBe(201) - expect(readAccessLog()).toContainEqual( - expect.objectContaining({ - collectionSlug: parentsSlug, - fieldName: 'accessCreateProbe', - operation: 'create', - source: 'field-access', - }), + const log = readAccessLog() + expect(log).toContainEqual( + expect.objectContaining({ collectionSlug: parentsSlug, fieldName: 'accessCreateProbe' }), ) + expect( + log.filter((e) => e.fieldName === 'accessCreateProbe' && e.collectionSlug === undefined), + ).toHaveLength(0) }) it('should pass collectionSlug to local read field access callbacks', async () => { @@ -101,14 +99,13 @@ describe('field access collection context', () => { overrideAccess: false, }) - expect(readAccessLog()).toContainEqual( - expect.objectContaining({ - collectionSlug: parentsSlug, - fieldName: 'accessReadProbe', - operation: 'read', - source: 'field-access', - }), + const log = readAccessLog() + expect(log).toContainEqual( + expect.objectContaining({ collectionSlug: parentsSlug, fieldName: 'accessReadProbe' }), ) + expect( + log.filter((e) => e.fieldName === 'accessReadProbe' && e.collectionSlug === undefined), + ).toHaveLength(0) }) it('should pass collectionSlug to REST read field access callbacks', async () => { @@ -125,14 +122,13 @@ describe('field access collection context', () => { const response = await restClient.GET(`/${parentsSlug}/${doc.id}`) expect(response.status).toBe(200) - expect(readAccessLog()).toContainEqual( - expect.objectContaining({ - collectionSlug: parentsSlug, - fieldName: 'accessReadProbe', - operation: 'read', - source: 'field-access', - }), + const log = readAccessLog() + expect(log).toContainEqual( + expect.objectContaining({ collectionSlug: parentsSlug, fieldName: 'accessReadProbe' }), ) + expect( + log.filter((e) => e.fieldName === 'accessReadProbe' && e.collectionSlug === undefined), + ).toHaveLength(0) }) it('should pass collectionSlug to GraphQL read field access callbacks', async () => { @@ -160,14 +156,13 @@ describe('field access collection context', () => { const result = await response.json() expect(result.errors).toBeUndefined() - expect(readAccessLog()).toContainEqual( - expect.objectContaining({ - collectionSlug: parentsSlug, - fieldName: 'accessReadProbe', - operation: 'read', - source: 'field-access', - }), + const log = readAccessLog() + expect(log).toContainEqual( + expect.objectContaining({ collectionSlug: parentsSlug, fieldName: 'accessReadProbe' }), ) + expect( + log.filter((e) => e.fieldName === 'accessReadProbe' && e.collectionSlug === undefined), + ).toHaveLength(0) }) it('should pass child collectionSlug when reading populated relationship children', async () => { @@ -197,14 +192,14 @@ describe('field access collection context', () => { overrideAccess: false, }) - expect(readAccessLog()).toContainEqual( - expect.objectContaining({ - collectionSlug: childrenSlug, - fieldName: 'childReadProbe', - operation: 'read', - source: 'field-access', - }), + // Child field access should receive the child collection's slug, not the parent's + const log = readAccessLog() + expect(log).toContainEqual( + expect.objectContaining({ collectionSlug: childrenSlug, fieldName: 'childReadProbe' }), ) + expect( + log.filter((e) => e.fieldName === 'childReadProbe' && e.collectionSlug === undefined), + ).toHaveLength(0) }) it('should pass collectionSlug to update field access callbacks', async () => { @@ -226,14 +221,13 @@ describe('field access collection context', () => { overrideAccess: false, }) - expect(readAccessLog()).toContainEqual( - expect.objectContaining({ - collectionSlug: parentsSlug, - fieldName: 'accessUpdateProbe', - operation: 'update', - source: 'field-access', - }), + const log = readAccessLog() + expect(log).toContainEqual( + expect.objectContaining({ collectionSlug: parentsSlug, fieldName: 'accessUpdateProbe' }), ) + expect( + log.filter((e) => e.fieldName === 'accessUpdateProbe' && e.collectionSlug === undefined), + ).toHaveLength(0) }) it('should pass collectionSlug to findDistinct field access callbacks', async () => { @@ -262,17 +256,18 @@ describe('field access collection context', () => { overrideAccess: false, }) - expect(readAccessLog()).toContainEqual( - expect.objectContaining({ - collectionSlug: parentsSlug, - fieldName: 'distinctProbe', - operation: 'read', - source: 'find-distinct', - }), + const log = readAccessLog() + expect(log).toContainEqual( + expect.objectContaining({ collectionSlug: parentsSlug, fieldName: 'distinctProbe' }), ) + expect( + log.filter((e) => e.fieldName === 'distinctProbe' && e.collectionSlug === undefined), + ).toHaveLength(0) }) it('should pass collectionSlug when building collection field permissions', async () => { + // payload.auth() calls getEntityPermissions for all registered collections, + // which calls populateFieldPermissions → field.access[operation] for each field. const req = await createLocalReq({}, payload) await payload.auth({ @@ -280,14 +275,13 @@ describe('field access collection context', () => { req, }) - expect(readAccessLog()).toContainEqual( - expect.objectContaining({ - collectionSlug: parentsSlug, - fieldName: 'permissionsProbe', - operation: 'read', - source: 'permissions', - }), + const log = readAccessLog() + expect(log).toContainEqual( + expect.objectContaining({ collectionSlug: parentsSlug, fieldName: 'permissionsProbe' }), ) + expect( + log.filter((e) => e.fieldName === 'permissionsProbe' && e.collectionSlug === undefined), + ).toHaveLength(0) }) it('should leave collectionSlug undefined for global field access callbacks', async () => { @@ -304,7 +298,8 @@ describe('field access collection context', () => { overrideAccess: false, }) - expect(readAccessLog()).toContainEqual( + const log = readAccessLog() + expect(log).toContainEqual( expect.objectContaining({ collectionSlug: undefined, fieldName: 'globalReadProbe', @@ -312,5 +307,9 @@ describe('field access collection context', () => { source: 'field-access', }), ) + // Verify it was NOT called with any collection slug + expect( + log.filter((e) => e.fieldName === 'globalReadProbe' && e.collectionSlug !== undefined), + ).toHaveLength(0) }) }) diff --git a/test/field-access-context/shared.ts b/test/field-access-context/shared.ts index b6248d080a4..cf085b46d79 100644 --- a/test/field-access-context/shared.ts +++ b/test/field-access-context/shared.ts @@ -28,9 +28,7 @@ export const recordAccess = ({ operation, source, }: Pick): FieldAccess => { - return (args) => { - const { collectionSlug } = args as { collectionSlug?: string } & typeof args - + return ({ collectionSlug }) => { pushAccessLog({ collectionSlug, fieldName, operation, source }) return true From a65650a8f1065fc62b9d41b6d6a54155c81ec645 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 18 Jun 2026 15:01:20 -0400 Subject: [PATCH 6/8] fix(ui): pass collectionSlug to field access read in addFieldStatePromise Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts index 86a705dacc5..239fc9785a9 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts @@ -213,6 +213,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom hasPermission = await field.access.read({ id, blockData, + collectionSlug, data: fullData, req, siblingData: data, From be15f0c70fcbd68597b5be7ab318d00cd83257b2 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 18 Jun 2026 15:09:32 -0400 Subject: [PATCH 7/8] feat(payload): add globalSlug to FieldAccessArgs and thread through all callsites Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/payload/src/fields/config/types.ts | 5 +++++ .../payload/src/fields/hooks/afterRead/promise.ts | 1 + .../src/fields/hooks/beforeValidate/promise.ts | 1 + .../getEntityPermissions/getEntityPermissions.ts | 1 + .../getEntityPermissions/populateFieldPermissions.ts | 12 ++++++++++++ .../fieldSchemasToFormState/addFieldStatePromise.ts | 3 +++ .../ui/src/forms/fieldSchemasToFormState/index.tsx | 3 +++ .../forms/fieldSchemasToFormState/iterateFields.ts | 3 +++ packages/ui/src/utilities/buildFormState.ts | 1 + test/field-access-context/int.spec.ts | 7 +++++-- test/field-access-context/shared.ts | 5 +++-- 11 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index e006621408a..b17c22eceef 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -252,6 +252,11 @@ export type FieldAccessArgs * The original data of the document before the `update` is applied. `doc` is undefined during the `create` operation. */ doc?: TData + /** + * Slug of the global that owns the field being evaluated. + * Undefined when the field belongs to a collection. + */ + globalSlug?: string /** * The `id` of the current document being read or updated. `id` is undefined during the `create` operation. */ diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index b8baffb5f7b..5cb214ed772 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -380,6 +380,7 @@ export const promise = async ({ collectionSlug: collection?.slug, data: doc, doc, + globalSlug: global?.slug, req, siblingData: siblingDoc, }) diff --git a/packages/payload/src/fields/hooks/beforeValidate/promise.ts b/packages/payload/src/fields/hooks/beforeValidate/promise.ts index baee30cb3eb..4e4282e6df5 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/promise.ts @@ -326,6 +326,7 @@ export const promise = async ({ collectionSlug: collection?.slug, data: data as Partial, doc, + globalSlug: global?.slug, req, siblingData, }) diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts index d0d8abd6779..3b7e4eead36 100644 --- a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -212,6 +212,7 @@ export async function getEntityPermissions use parent permissions object blockReferencesPermissions, @@ -302,6 +312,7 @@ export const populateFieldPermissions = ({ collectionSlug, data, fields: tab.fields, + globalSlug, operations, parentPermissionsObject: tabPermissions, permissionsObject: tabPermissions.fields, @@ -315,6 +326,7 @@ export const populateFieldPermissions = ({ collectionSlug, data, fields: tab.fields, + globalSlug, operations, // Tab does not have a name here => use parent permissions object blockReferencesPermissions, diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts index 239fc9785a9..bbaa3200b20 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts @@ -64,6 +64,7 @@ export type AddFieldStatePromiseArgs = { */ forceFullValue?: boolean fullData: Data + globalSlug?: string id: number | string /** * Whether the field schema should be included in the state @@ -124,6 +125,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom filter, forceFullValue = false, fullData, + globalSlug, includeSchema = false, indexPath, mockRSCs, @@ -215,6 +217,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom blockData, collectionSlug, data: fullData, + globalSlug, req, siblingData: data, }) diff --git a/packages/ui/src/forms/fieldSchemasToFormState/index.tsx b/packages/ui/src/forms/fieldSchemasToFormState/index.tsx index 741f139dcef..849c0ab4284 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/index.tsx +++ b/packages/ui/src/forms/fieldSchemasToFormState/index.tsx @@ -51,6 +51,7 @@ type Args = { * then the field schema map is not required. */ fieldSchemaMap: FieldSchemaMap | undefined + globalSlug?: string id?: number | string /** * Validation, filterOptions and read access control will receive the `blockData`, which is the data of the nearest parent block. You can pass in @@ -88,6 +89,7 @@ export const fieldSchemasToFormState = async ({ documentData, fields, fieldSchemaMap, + globalSlug, initialBlockData, mockRSCs, operation, @@ -145,6 +147,7 @@ export const fieldSchemasToFormState = async ({ fields, fieldSchemaMap, fullData, + globalSlug, mockRSCs, operation, parentIndexPath: '', diff --git a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts index b5719fadaf1..636f0bc83d3 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts @@ -43,6 +43,7 @@ type Args = { */ forceFullValue?: boolean fullData: Data + globalSlug?: string id?: number | string /** * Whether the field schema should be included in the state. @default false @@ -97,6 +98,7 @@ export const iterateFields = async ({ filter, forceFullValue = false, fullData, + globalSlug, includeSchema = false, mockRSCs, omitParents = false, @@ -185,6 +187,7 @@ export const iterateFields = async ({ filter, forceFullValue, fullData, + globalSlug, includeSchema, indexPath, mockRSCs, diff --git a/packages/ui/src/utilities/buildFormState.ts b/packages/ui/src/utilities/buildFormState.ts index 6ed2c07aade..300153756e4 100644 --- a/packages/ui/src/utilities/buildFormState.ts +++ b/packages/ui/src/utilities/buildFormState.ts @@ -215,6 +215,7 @@ export const buildFormState = async ( documentData, fields, fieldSchemaMap: schemaMap, + globalSlug, initialBlockData: blockData, mockRSCs, operation, diff --git a/test/field-access-context/int.spec.ts b/test/field-access-context/int.spec.ts index 80e87e2b49c..c4b63b5cead 100644 --- a/test/field-access-context/int.spec.ts +++ b/test/field-access-context/int.spec.ts @@ -284,7 +284,7 @@ describe('field access collection context', () => { ).toHaveLength(0) }) - it('should leave collectionSlug undefined for global field access callbacks', async () => { + it('should leave collectionSlug undefined and set globalSlug for global field access callbacks', async () => { await payload.updateGlobal({ slug: globalSlug, data: { @@ -303,13 +303,16 @@ describe('field access collection context', () => { expect.objectContaining({ collectionSlug: undefined, fieldName: 'globalReadProbe', + globalSlug, operation: 'read', source: 'field-access', }), ) - // Verify it was NOT called with any collection slug expect( log.filter((e) => e.fieldName === 'globalReadProbe' && e.collectionSlug !== undefined), ).toHaveLength(0) + expect( + log.filter((e) => e.fieldName === 'globalReadProbe' && e.globalSlug === undefined), + ).toHaveLength(0) }) }) diff --git a/test/field-access-context/shared.ts b/test/field-access-context/shared.ts index cf085b46d79..0bf7ee44b0e 100644 --- a/test/field-access-context/shared.ts +++ b/test/field-access-context/shared.ts @@ -7,6 +7,7 @@ export const globalSlug = 'field-access-context-global' export type AccessLogEntry = { collectionSlug: string | undefined fieldName: string + globalSlug: string | undefined operation: 'create' | 'read' | 'update' source: 'field-access' | 'find-distinct' | 'permissions' } @@ -28,8 +29,8 @@ export const recordAccess = ({ operation, source, }: Pick): FieldAccess => { - return ({ collectionSlug }) => { - pushAccessLog({ collectionSlug, fieldName, operation, source }) + return ({ collectionSlug, globalSlug: gs }) => { + pushAccessLog({ collectionSlug, fieldName, globalSlug: gs, operation, source }) return true } From b2d43c5c71ff9a4e65fade5427363246198e9d13 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 18 Jun 2026 15:20:05 -0400 Subject: [PATCH 8/8] feat(payload): thread collection/global config objects through field access args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align FieldAccessArgs with FieldHookArgs pattern: pass full SanitizedCollectionConfig / SanitizedGlobalConfig objects (or null) instead of plain slug strings. Updated callsites: - fields/hooks/afterRead/promise.ts - fields/hooks/beforeValidate/promise.ts - collections/operations/findDistinct.ts - utilities/getEntityPermissions/populateFieldPermissions.ts - utilities/getEntityPermissions/getEntityPermissions.ts - packages/ui addFieldStatePromise.ts (looks up config from req.payload) Test: add global→child relationship probe asserting child field access receives child collection context when populated from a global. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...2026-06-18-field-access-collection-slug.md | 439 ++++++++++++++++++ .../collections/operations/findDistinct.ts | 3 +- packages/payload/src/fields/config/types.ts | 14 +- .../src/fields/hooks/afterRead/promise.ts | 4 +- .../fields/hooks/beforeValidate/promise.ts | 4 +- .../getEntityPermissions.ts | 4 +- .../populateFieldPermissions.ts | 44 +- .../addFieldStatePromise.ts | 8 +- test/_community/payload-types.ts | 54 +-- .../globals/AccessContextGlobal.ts | 7 +- test/field-access-context/int.spec.ts | 41 ++ test/field-access-context/shared.ts | 10 +- 12 files changed, 559 insertions(+), 73 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-18-field-access-collection-slug.md diff --git a/docs/superpowers/plans/2026-06-18-field-access-collection-slug.md b/docs/superpowers/plans/2026-06-18-field-access-collection-slug.md new file mode 100644 index 00000000000..dad6dbd7896 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-field-access-collection-slug.md @@ -0,0 +1,439 @@ +# Field Access Collection Slug Context Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expose the owning collection slug (`collectionSlug`) to field access functions for create/read/update across all core field-access execution paths, including relationship child traversal and permissions projection paths. + +**Architecture:** Add one optional property to `FieldAccessArgs`, then thread it from the existing traversal/access call sites that already know the owning collection config. Keep globals behavior explicit by passing `undefined` for global fields. Validate behavior with a dedicated integration suite that exercises Local API, REST, and GraphQL read paths plus create/update and `findDistinct`. + +**Tech Stack:** TypeScript, Payload core field hook/access pipeline, Vitest integration tests, Next REST/GraphQL test client helpers. + +## Global Constraints + +- Preserve backward compatibility: new access arg must be optional. +- Do not infer collection from `req.routeParams`; pass explicit owning collection slug from traversal context. +- Keep behavior identical for globals (`collectionSlug` undefined). + +--- + +## File Structure + +- **Create** `test/field-access-context/config.ts` — dedicated test config entrypoint. +- **Create** `test/field-access-context/int.spec.ts` — integration assertions for local/REST/GraphQL parity and relationship child behavior. +- **Create** `test/field-access-context/shared.ts` — shared slugs and recorder utilities. +- **Create** `test/field-access-context/collections/Parents/index.ts` — parent collection with `create/read/update` field access probes and relationship field. +- **Create** `test/field-access-context/collections/Children/index.ts` — child collection with `read` field access probe. +- **Create** `test/field-access-context/globals/AccessContextGlobal.ts` — global with field `read` access probe. +- **Modify** `packages/payload/src/fields/config/types.ts` — add optional `collectionSlug` to `FieldAccessArgs`. +- **Modify** `packages/payload/src/fields/hooks/beforeValidate/promise.ts` — pass `collectionSlug` for `create/update` field access checks. +- **Modify** `packages/payload/src/fields/hooks/afterRead/promise.ts` — pass `collectionSlug` for `read` field access checks. +- **Modify** `packages/payload/src/collections/operations/findDistinct.ts` — pass `collectionSlug` for direct field `read` access check. +- **Modify** `packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts` — pass owning entity collection slug into field-permission traversal. +- **Modify** `packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts` — accept/thread `collectionSlug` and pass it to all field access calls. + +### Task 1: Add failing integration coverage for field access collection context + +**Files:** + +- Create: `test/field-access-context/shared.ts` +- Create: `test/field-access-context/collections/Parents/index.ts` +- Create: `test/field-access-context/collections/Children/index.ts` +- Create: `test/field-access-context/globals/AccessContextGlobal.ts` +- Create: `test/field-access-context/config.ts` +- Create: `test/field-access-context/int.spec.ts` + +**Interfaces:** + +- Consumes: existing `initPayloadInt`, `NextRESTClient`, and Payload Local API methods (`create`, `update`, `find`, `findByID`, `findDistinct`, `findGlobal`). +- Produces: deterministic test failures proving `collectionSlug` is currently absent and required. + +- [x] **Step 1: Create shared slugs and access recorder utilities** + +```ts +// test/field-access-context/shared.ts +export const parentsSlug = 'field-access-context-parents' +export const childrenSlug = 'field-access-context-children' +export const globalSlug = 'field-access-context-global' + +export type AccessLogEntry = { + collectionSlug: string | undefined + fieldName: string + operation: 'create' | 'read' | 'update' + source: 'field-access' | 'find-distinct' | 'permissions' +} + +const accessLog: AccessLogEntry[] = [] + +export const pushAccessLog = (entry: AccessLogEntry): void => { + accessLog.push(entry) +} + +export const readAccessLog = (): AccessLogEntry[] => [...accessLog] + +export const resetAccessLog = (): void => { + accessLog.length = 0 +} +``` + +- [x] **Step 2: Create parent/child/global fixtures that record field-access args** + +```ts +// test/field-access-context/collections/Parents/index.ts (excerpt) +{ + name: 'accessCreateProbe', + type: 'text', + access: { + create: ({ collectionSlug }) => { + pushAccessLog({ collectionSlug, fieldName: 'accessCreateProbe', operation: 'create', source: 'field-access' }) + return true + }, + }, +} +``` + +```ts +// test/field-access-context/collections/Children/index.ts (excerpt) +{ + name: 'childReadProbe', + type: 'text', + access: { + read: ({ collectionSlug }) => { + pushAccessLog({ collectionSlug, fieldName: 'childReadProbe', operation: 'read', source: 'field-access' }) + return true + }, + }, +} +``` + +```ts +// test/field-access-context/globals/AccessContextGlobal.ts (excerpt) +{ + name: 'globalReadProbe', + type: 'text', + access: { + read: ({ collectionSlug }) => { + pushAccessLog({ collectionSlug, fieldName: 'globalReadProbe', operation: 'read', source: 'field-access' }) + return true + }, + }, +} +``` + +- [x] **Step 3: Write failing tests for local/REST/GraphQL and relationship-child context** + +```ts +// test/field-access-context/int.spec.ts (key assertion shape) +expect(readAccessLog()).toContainEqual( + expect.objectContaining({ + fieldName: 'accessReadProbe', + operation: 'read', + collectionSlug: parentsSlug, + }), +) + +expect(readAccessLog()).toContainEqual( + expect.objectContaining({ + fieldName: 'childReadProbe', + operation: 'read', + collectionSlug: childrenSlug, + }), +) +``` + +- [x] **Step 4: Add failing tests for create/update/findDistinct and globals** + +```ts +// create/update checks +expect(readAccessLog()).toContainEqual( + expect.objectContaining({ + fieldName: 'accessCreateProbe', + operation: 'create', + collectionSlug: parentsSlug, + }), +) +expect(readAccessLog()).toContainEqual( + expect.objectContaining({ + fieldName: 'accessUpdateProbe', + operation: 'update', + collectionSlug: parentsSlug, + }), +) + +// globals should remain undefined +expect(readAccessLog()).toContainEqual( + expect.objectContaining({ + fieldName: 'globalReadProbe', + operation: 'read', + collectionSlug: undefined, + }), +) +``` + +- [x] **Step 5: Run targeted tests to confirm red state** + +Run: `pnpm run test:int field-access-context` +Expected: FAIL with assertions showing `collectionSlug` is `undefined` in one or more field access callbacks. + +- [x] **Step 6: Commit failing tests** + +```bash +git add test/field-access-context +git commit -m "test: add failing coverage for field access collection slug context" +``` + +### Task 2: Extend field access arg type surface + +**Files:** + +- Modify: `packages/payload/src/fields/config/types.ts` +- Test: `test/field-access-context/int.spec.ts` + +**Interfaces:** + +- Consumes: current `FieldAccessArgs`. +- Produces: `FieldAccessArgs` with optional `collectionSlug?: string`. + +- [x] **Step 1: Add optional `collectionSlug` to `FieldAccessArgs`** + +```ts +export type FieldAccessArgs< + TData extends TypeWithID = any, + TSiblingData = any, +> = { + // ... + /** + * Slug of the collection that owns the field being evaluated. + * Undefined when the field belongs to a global. + */ + collectionSlug?: string + req: PayloadRequest + // ... +} +``` + +- [x] **Step 2: Run the focused test suite to keep expected failures stable** + +Run: `pnpm run test:int field-access-context` +Expected: FAIL (runtime threading not implemented yet), with no TypeScript compile/runtime errors caused by the new arg. + +- [x] **Step 3: Commit type surface update** + +```bash +git add packages/payload/src/fields/config/types.ts +git commit -m "feat(payload): add collectionSlug to FieldAccessArgs" +``` + +### Task 3: Thread collection slug through create/read/update field-access execution paths + +**Files:** + +- Modify: `packages/payload/src/fields/hooks/beforeValidate/promise.ts` +- Modify: `packages/payload/src/fields/hooks/afterRead/promise.ts` +- Modify: `packages/payload/src/collections/operations/findDistinct.ts` + +**Interfaces:** + +- Consumes: traversal `collection` config in hook promise functions; `collectionConfig.slug` in findDistinct. +- Produces: field access callback args containing `collectionSlug` for create/read/update checks. + +- [x] **Step 1: Thread `collection?.slug` into beforeValidate field access (`create`/`update`)** + +```ts +// beforeValidate/promise.ts +await field.access[operation]({ + id, + blockData, + collectionSlug: collection?.slug, + data: data as Partial, + doc, + req, + siblingData, +}) +``` + +- [x] **Step 2: Thread `collection?.slug` into afterRead field access (`read`)** + +```ts +// afterRead/promise.ts +await field.access.read({ + id: doc.id as number | string, + blockData, + collectionSlug: collection?.slug, + data: doc, + doc, + req, + siblingData: siblingDoc, +}) +``` + +- [x] **Step 3: Thread collection slug in direct field access path (`findDistinct`)** + +```ts +// collections/operations/findDistinct.ts +const hasAccess = await fieldResult.field.access.read({ + collectionSlug: collectionConfig.slug, + req, +}) +``` + +- [x] **Step 4: Run targeted tests** + +Run: `pnpm run test:int field-access-context` +Expected: PASS for create/update/read/findDistinct path assertions; any remaining failures should be permissions-path specific. + +- [x] **Step 5: Commit runtime threading changes** + +```bash +git add \ + packages/payload/src/fields/hooks/beforeValidate/promise.ts \ + packages/payload/src/fields/hooks/afterRead/promise.ts \ + packages/payload/src/collections/operations/findDistinct.ts +git commit -m "fix(payload): pass owning collection slug to field access callbacks" +``` + +### Task 4: Thread collection slug through permissions traversal (`populateFieldPermissions`) + +**Files:** + +- Modify: `packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts` +- Modify: `packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts` +- Modify: `test/field-access-context/int.spec.ts` + +**Interfaces:** + +- Consumes: `entityType`, `entity.slug`, and existing recursive `populateFieldPermissions` traversal. +- Produces: `collectionSlug` passed to field access checks run during permissions computation. + +- [x] **Step 1: Add optional `collectionSlug` param to populateFieldPermissions signature** + +```ts +export const populateFieldPermissions = ({ + collectionSlug, + id, + blockReferencesPermissions, + data, + fields, + operations, + parentPermissionsObject, + permissionsObject, + promises, + req, +}: { + collectionSlug?: string + // existing args... +}): void => { +``` + +- [x] **Step 2: Pass `collectionSlug` to all `field.access[operation]` calls and recursive invocations** + +```ts +const accessResult = field.access[operation]({ + collectionSlug, + id, + data, + doc: data, + req, +}) +``` + +```ts +populateFieldPermissions({ + collectionSlug, + id, + blockReferencesPermissions, + data, + fields: field.fields, + operations, + parentPermissionsObject: fieldPermissions, + permissionsObject: fieldPermissions.fields, + promises, + req, +}) +``` + +- [x] **Step 3: Pass root collection slug from getEntityPermissions** + +```ts +populateFieldPermissions({ + blockReferencesPermissions, + collectionSlug: entityType === 'collection' ? entity.slug : undefined, + data, + fields: entity.fields, + operations, + parentPermissionsObject: entityPermissions, + permissionsObject: fieldsPermissions, + promises, + req, +}) +``` + +- [x] **Step 4: Add/enable test coverage for permissions access path** + +```ts +// test/field-access-context/int.spec.ts +await restClient.GET('/access') +expect(readAccessLog()).toContainEqual( + expect.objectContaining({ + source: 'permissions', + operation: 'read', + collectionSlug: parentsSlug, + }), +) +``` + +- [x] **Step 5: Run focused suite** + +Run: `pnpm run test:int field-access-context` +Expected: PASS, including permissions-path assertions. + +- [x] **Step 6: Commit permissions-path changes** + +```bash +git add \ + packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts \ + packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts \ + test/field-access-context/int.spec.ts +git commit -m "fix(payload): thread collection slug through field permissions access traversal" +``` + +### Task 5: Cross-path verification and cleanup + +**Files:** + +- Modify (if needed): `test/field-access-context/int.spec.ts` + +**Interfaces:** + +- Consumes: completed implementation from Tasks 2-4. +- Produces: confidence that local/REST/GraphQL parity and relationship context-switch behavior remain correct. + +- [x] **Step 1: Run suite that directly covers GraphQL transport behavior** + +Run: `pnpm run test:int graphql` +Expected: PASS + +- [x] **Step 2: Run suite that heavily exercises field access behavior** + +Run: `pnpm run test:int access-control` +Expected: PASS + +- [x] **Step 3: Re-run feature suite as final guard** + +Run: `pnpm run test:int field-access-context` +Expected: PASS + +- [x] **Step 4: Commit any final test stabilization edits** + +```bash +git add test/field-access-context/int.spec.ts +git commit -m "test: stabilize field access collection slug coverage" +``` + +- [x] **Step 5: Prepare branch for review** + +```bash +git status --short +git log --oneline -n 8 +``` + +Expected: clean working tree and commit history matching tasks above. diff --git a/packages/payload/src/collections/operations/findDistinct.ts b/packages/payload/src/collections/operations/findDistinct.ts index 3d8b0ca7925..8601d8bbc7d 100644 --- a/packages/payload/src/collections/operations/findDistinct.ts +++ b/packages/payload/src/collections/operations/findDistinct.ts @@ -132,7 +132,8 @@ export const findDistinctOperation = async ( if (fieldResult.field.access?.read) { const hasAccess = await fieldResult.field.access.read({ - collectionSlug: collectionConfig.slug, + collection: collectionConfig, + global: null, req, }) if (!hasAccess) { diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index b17c22eceef..a4d6f53458d 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -239,11 +239,8 @@ export type FieldAccessArgs * The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`. */ blockData?: JsonObject | undefined - /** - * Slug of the collection that owns the field being evaluated. - * Undefined when the field belongs to a global. - */ - collectionSlug?: string + /** The collection which the field belongs to. If the field belongs to a global, this will be null. */ + collection?: null | SanitizedCollectionConfig /** * The incoming, top-level document data used to `create` or `update` the document with. */ @@ -252,11 +249,8 @@ export type FieldAccessArgs * The original data of the document before the `update` is applied. `doc` is undefined during the `create` operation. */ doc?: TData - /** - * Slug of the global that owns the field being evaluated. - * Undefined when the field belongs to a collection. - */ - globalSlug?: string + /** The global which the field belongs to. If the field belongs to a collection, this will be null. */ + global?: null | SanitizedGlobalConfig /** * The `id` of the current document being read or updated. `id` is undefined during the `create` operation. */ diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index 5cb214ed772..d8da6de41b3 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -377,10 +377,10 @@ export const promise = async ({ : await field.access.read({ id: doc.id as number | string, blockData, - collectionSlug: collection?.slug, + collection, data: doc, doc, - globalSlug: global?.slug, + global, req, siblingData: siblingDoc, }) diff --git a/packages/payload/src/fields/hooks/beforeValidate/promise.ts b/packages/payload/src/fields/hooks/beforeValidate/promise.ts index 4e4282e6df5..816aacb20bd 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/promise.ts @@ -323,10 +323,10 @@ export const promise = async ({ : await field.access[operation]({ id, blockData, - collectionSlug: collection?.slug, + collection, data: data as Partial, doc, - globalSlug: global?.slug, + global, req, siblingData, }) diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts index 3b7e4eead36..a2bd2de3782 100644 --- a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -209,10 +209,10 @@ export async function getEntityPermissions same parentPermissionsObject populateFieldPermissions({ id, - collectionSlug, + collection, data, fields: field.fields, - globalSlug, + global, operations, // Field does not have a name here => use parent permissions object blockReferencesPermissions, @@ -309,10 +305,10 @@ export const populateFieldPermissions = ({ populateFieldPermissions({ id, blockReferencesPermissions, - collectionSlug, + collection, data, fields: tab.fields, - globalSlug, + global, operations, parentPermissionsObject: tabPermissions, permissionsObject: tabPermissions.fields, @@ -323,10 +319,10 @@ export const populateFieldPermissions = ({ // Tab does not have a name => same parentPermissionsObject populateFieldPermissions({ id, - collectionSlug, + collection, data, fields: tab.fields, - globalSlug, + global, operations, // Tab does not have a name here => use parent permissions object blockReferencesPermissions, diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts index bbaa3200b20..32b371f2a2c 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts @@ -215,9 +215,13 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom hasPermission = await field.access.read({ id, blockData, - collectionSlug, + collection: collectionSlug + ? (req.payload.collections[collectionSlug]?.config ?? null) + : null, data: fullData, - globalSlug, + global: globalSlug + ? (req.payload.globals.config.find((g) => g.slug === globalSlug) ?? null) + : null, req, siblingData: data, }) diff --git a/test/_community/payload-types.ts b/test/_community/payload-types.ts index c9570716f53..035924b4ffd 100644 --- a/test/_community/payload-types.ts +++ b/test/_community/payload-types.ts @@ -62,16 +62,16 @@ export type SupportedTimezones = | 'Pacific/Fiji'; /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "LexicalNodes_342F8217". + * via the `definition` "LexicalNodes_00871687". */ -export type LexicalNodes_342F8217 = +export type LexicalNodes_00871687 = | SerializedTextNode | SerializedTabNode | SerializedLineBreakNode - | SerializedParagraphNode + | SerializedParagraphNode | SerializedHorizontalRuleNode | SerializedUploadNode<'media'> - | SerializedQuoteNode + | SerializedQuoteNode | SerializedRelationshipNode< | 'posts' | 'users' @@ -81,11 +81,11 @@ export type LexicalNodes_342F8217 = | 'payload-preferences' | 'payload-migrations' > - | SerializedAutoLinkNode - | SerializedLinkNode - | SerializedListNode - | SerializedListItemNode - | SerializedHeadingNode; + | SerializedAutoLinkNode + | SerializedLinkNode + | SerializedListNode + | SerializedListItemNode + | SerializedHeadingNode; export interface Config { auth: { @@ -114,7 +114,7 @@ export interface Config { 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { - defaultIDType: number; + defaultIDType: string; }; fallbackLocale: null; globals: { @@ -156,9 +156,9 @@ export interface UserAuthOperations { * via the `definition` "posts". */ export interface Post { - id: number; + id: string; title?: string | null; - content?: LexicalRichText | null; + content?: LexicalRichText | null; updatedAt: string; createdAt: string; } @@ -167,7 +167,7 @@ export interface Post { * via the `definition` "media". */ export interface Media { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -211,7 +211,7 @@ export interface Media { * via the `definition` "users". */ export interface User { - id: number; + id: string; updatedAt: string; createdAt: string; email: string; @@ -236,7 +236,7 @@ export interface User { * via the `definition` "payload-mcp-api-keys". */ export interface PayloadMcpApiKey { - id: number; + id: string; apiKey: string; apiKeyIndex: string; access?: @@ -251,7 +251,7 @@ export interface PayloadMcpApiKey { label?: string | null; description?: string | null; lastUsed?: string | null; - user: number | User; + user: string | User; overrideAccess?: boolean | null; updatedAt: string; createdAt: string; @@ -261,7 +261,7 @@ export interface PayloadMcpApiKey { * via the `definition` "payload-kv". */ export interface PayloadKv { - id: number; + id: string; key: string; data: | { @@ -278,28 +278,28 @@ export interface PayloadKv { * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: number; + id: string; document?: | ({ relationTo: 'posts'; - value: number | Post; + value: string | Post; } | null) | ({ relationTo: 'media'; - value: number | Media; + value: string | Media; } | null) | ({ relationTo: 'users'; - value: number | User; + value: string | User; } | null) | ({ relationTo: 'payload-mcp-api-keys'; - value: number | PayloadMcpApiKey; + value: string | PayloadMcpApiKey; } | null); globalSlug?: string | null; user: { relationTo: 'users'; - value: number | User; + value: string | User; }; updatedAt: string; createdAt: string; @@ -309,10 +309,10 @@ export interface PayloadLockedDocument { * via the `definition` "payload-preferences". */ export interface PayloadPreference { - id: number; + id: string; user: { relationTo: 'users'; - value: number | User; + value: string | User; }; key?: string | null; value?: @@ -332,7 +332,7 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: number; + id: string; name?: string | null; batch?: number | null; updatedAt: string; @@ -482,7 +482,7 @@ export interface PayloadMigrationsSelect { * via the `definition` "menu". */ export interface Menu { - id: number; + id: string; globalText?: string | null; updatedAt?: string | null; createdAt?: string | null; diff --git a/test/field-access-context/globals/AccessContextGlobal.ts b/test/field-access-context/globals/AccessContextGlobal.ts index a86188c5989..a45e10a3a41 100644 --- a/test/field-access-context/globals/AccessContextGlobal.ts +++ b/test/field-access-context/globals/AccessContextGlobal.ts @@ -1,6 +1,6 @@ import type { GlobalConfig } from 'payload' -import { globalSlug, recordAccess } from '../shared.js' +import { childrenSlug, globalSlug, recordAccess } from '../shared.js' export const AccessContextGlobal: GlobalConfig = { slug: globalSlug, @@ -20,5 +20,10 @@ export const AccessContextGlobal: GlobalConfig = { }), }, }, + { + name: 'globalChild', + type: 'relationship', + relationTo: childrenSlug, + }, ], } diff --git a/test/field-access-context/int.spec.ts b/test/field-access-context/int.spec.ts index c4b63b5cead..b6afc10fed7 100644 --- a/test/field-access-context/int.spec.ts +++ b/test/field-access-context/int.spec.ts @@ -315,4 +315,45 @@ describe('field access collection context', () => { log.filter((e) => e.fieldName === 'globalReadProbe' && e.globalSlug === undefined), ).toHaveLength(0) }) + + it('should pass child collectionSlug when reading a populated relationship child from a global', async () => { + const child = await payload.create({ + collection: childrenSlug, + data: { + childReadProbe: 'global relationship child', + title: 'global relationship child', + }, + }) + childIDs.push(child.id) + + await payload.updateGlobal({ + slug: globalSlug, + data: { + globalChild: child.id, + }, + }) + resetAccessLog() + + await payload.findGlobal({ + slug: globalSlug, + depth: 1, + overrideAccess: false, + }) + + // The child's field access should receive the child collection context, not the global context + const log = readAccessLog() + expect(log).toContainEqual( + expect.objectContaining({ + collectionSlug: childrenSlug, + fieldName: 'childReadProbe', + globalSlug: undefined, + }), + ) + expect( + log.filter((e) => e.fieldName === 'childReadProbe' && e.collectionSlug === undefined), + ).toHaveLength(0) + expect( + log.filter((e) => e.fieldName === 'childReadProbe' && e.globalSlug !== undefined), + ).toHaveLength(0) + }) }) diff --git a/test/field-access-context/shared.ts b/test/field-access-context/shared.ts index 0bf7ee44b0e..27f8416dc89 100644 --- a/test/field-access-context/shared.ts +++ b/test/field-access-context/shared.ts @@ -29,8 +29,14 @@ export const recordAccess = ({ operation, source, }: Pick): FieldAccess => { - return ({ collectionSlug, globalSlug: gs }) => { - pushAccessLog({ collectionSlug, fieldName, globalSlug: gs, operation, source }) + return ({ collection, global: g }) => { + pushAccessLog({ + collectionSlug: collection?.slug, + fieldName, + globalSlug: g?.slug, + operation, + source, + }) return true }