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 0e63f0d5de5..8601d8bbc7d 100644 --- a/packages/payload/src/collections/operations/findDistinct.ts +++ b/packages/payload/src/collections/operations/findDistinct.ts @@ -131,7 +131,11 @@ export const findDistinctOperation = async ( } if (fieldResult.field.access?.read) { - const hasAccess = await fieldResult.field.access.read({ req }) + const hasAccess = await fieldResult.field.access.read({ + collection: collectionConfig, + global: null, + req, + }) if (!hasAccess) { throw new Forbidden(req.t) } diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 67c1f471113..a4d6f53458d 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -239,6 +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 + /** 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. */ @@ -247,6 +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 + /** 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 1665f739405..d8da6de41b3 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -377,8 +377,10 @@ export const promise = async ({ : await field.access.read({ id: doc.id as number | string, blockData, + collection, data: doc, doc, + 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 28b2c8e587e..816aacb20bd 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/promise.ts @@ -323,8 +323,10 @@ export const promise = async ({ : await field.access[operation]({ id, blockData, + collection, data: data as Partial, doc, + global, req, siblingData, }) diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts index e4bf4476051..a2bd2de3782 100644 --- a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -209,8 +209,10 @@ export async function getEntityPermissions same parentPermissionsObject populateFieldPermissions({ id, + collection, data, fields: field.fields, + global, operations, // Field does not have a name here => use parent permissions object blockReferencesPermissions, @@ -289,8 +305,10 @@ export const populateFieldPermissions = ({ populateFieldPermissions({ id, blockReferencesPermissions, + collection, data, fields: tab.fields, + global, operations, parentPermissionsObject: tabPermissions, permissionsObject: tabPermissions.fields, @@ -301,8 +319,10 @@ export const populateFieldPermissions = ({ // Tab does not have a name => same parentPermissionsObject populateFieldPermissions({ id, + collection, data, fields: tab.fields, + 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 86a705dacc5..32b371f2a2c 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, @@ -213,7 +215,13 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom hasPermission = await field.access.read({ id, blockData, + collection: collectionSlug + ? (req.payload.collections[collectionSlug]?.config ?? null) + : null, data: fullData, + global: globalSlug + ? (req.payload.globals.config.find((g) => g.slug === globalSlug) ?? null) + : null, 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/_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/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..a45e10a3a41 --- /dev/null +++ b/test/field-access-context/globals/AccessContextGlobal.ts @@ -0,0 +1,29 @@ +import type { GlobalConfig } from 'payload' + +import { childrenSlug, 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', + }), + }, + }, + { + name: 'globalChild', + type: 'relationship', + relationTo: childrenSlug, + }, + ], +} diff --git a/test/field-access-context/int.spec.ts b/test/field-access-context/int.spec.ts new file mode 100644 index 00000000000..b6afc10fed7 --- /dev/null +++ b/test/field-access-context/int.spec.ts @@ -0,0 +1,359 @@ +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({ id, collection: parentsSlug }) + } + parentIDs.length = 0 + + for (const id of childIDs) { + await payload.delete({ id, collection: childrenSlug }) + } + 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) + + 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 () => { + 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) + 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 () => { + 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, + }) + + 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 () => { + 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) + 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 () => { + 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() + 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 () => { + 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, + }) + + // 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 () => { + 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, + }) + + 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 () => { + 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, + }) + + 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({ + headers: new Headers(), + req, + }) + + 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 and set globalSlug for global field access callbacks', async () => { + await payload.updateGlobal({ + slug: globalSlug, + data: { + globalReadProbe: 'global read', + }, + }) + resetAccessLog() + + await payload.findGlobal({ + slug: globalSlug, + overrideAccess: false, + }) + + const log = readAccessLog() + expect(log).toContainEqual( + expect.objectContaining({ + collectionSlug: undefined, + fieldName: 'globalReadProbe', + globalSlug, + operation: 'read', + source: 'field-access', + }), + ) + expect( + log.filter((e) => e.fieldName === 'globalReadProbe' && e.collectionSlug !== undefined), + ).toHaveLength(0) + expect( + 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 new file mode 100644 index 00000000000..27f8416dc89 --- /dev/null +++ b/test/field-access-context/shared.ts @@ -0,0 +1,43 @@ +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 + globalSlug: string | undefined + 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 ({ collection, global: g }) => { + pushAccessLog({ + collectionSlug: collection?.slug, + fieldName, + globalSlug: g?.slug, + 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"] +}