From 05eaa5fb45785c7c165c077a03f5e3e30eb923d6 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Fri, 14 Nov 2025 14:55:43 -0800 Subject: [PATCH 01/42] improve test --- test/access-control/config.ts | 27 +++++ test/access-control/e2e.spec.ts | 72 +++++++++++ test/access-control/payload-types.ts | 173 ++++++++++++++++----------- 3 files changed, 200 insertions(+), 72 deletions(-) diff --git a/test/access-control/config.ts b/test/access-control/config.ts index eb3ff36b6c1..fdc1545353d 100644 --- a/test/access-control/config.ts +++ b/test/access-control/config.ts @@ -624,6 +624,33 @@ export default buildConfigWithDefaults( Hooks, Auth, ReadRestricted, + { + slug: 'field-restricted-update-based-on-data', + fields: [ + { + name: 'restricted', + type: 'text', + access: { + update: ({ data, doc }) => { + console.log(`update access control.`, { + data, + doc, + returned: !data?.field1, + }) + return !data?.isRestricted + }, + }, + }, + { + name: 'doesNothing', + type: 'checkbox', + }, + { + name: 'isRestricted', + type: 'checkbox', + }, + ], + }, ], globals: [ { diff --git a/test/access-control/e2e.spec.ts b/test/access-control/e2e.spec.ts index 23e490a4e8a..accaad9881c 100644 --- a/test/access-control/e2e.spec.ts +++ b/test/access-control/e2e.spec.ts @@ -17,6 +17,7 @@ import { saveDocAndAssert, } from '../helpers.js' import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' +import { assertNetworkRequests } from '../helpers/e2e/assertNetworkRequests.js' import { login } from '../helpers/e2e/auth/login.js' import { openListFilters } from '../helpers/e2e/filters/index.js' import { openGroupBy } from '../helpers/e2e/groupBy/index.js' @@ -142,6 +143,77 @@ describe('Access Control', () => { await page.goto(url.account) await expect(page.locator('#field-roles')).toBeHidden() }) + + test('ensure field with update access control is readOnly during both initial load and after saving', async () => { + async function waitForFormState(action: 'reload' | 'save') { + await assertNetworkRequests( + page, + '/admin/collections/field-restricted-update-based-on-data', + async () => { + if (action === 'save') { + await saveDocAndAssert(page) + } else { + await page.reload() + } + }, + { + minimumNumberOfRequests: action === 'save' ? 2 : 1, + allowedNumberOfRequests: action === 'save' ? 2 : 1, + }, + ) + } + // Reproduces a bug where the shape of the `data` object passed to the field update access control function is incorrect + // after saving the document, and correct on initial load. + + await payload.delete({ + collection: 'field-restricted-update-based-on-data', + }) + + const collectionURL = new AdminUrlUtil(serverURL, 'field-restricted-update-based-on-data') + + // Create document via UI, to test if the field's readOnly state is correct throughout the entire lifecycle of the document. + + await page.goto(collectionURL.create) + + const restrictedField = page.locator('#field-restricted') + const isRestrictedCheckbox = page.locator('#field-isRestricted') + + await expect(restrictedField).toBeEnabled() + + await isRestrictedCheckbox.check() + await expect(isRestrictedCheckbox).toBeChecked() + + await waitForFormState('save') + await expect(restrictedField).toBeDisabled() + + await waitForFormState('reload') + await expect(restrictedField).toBeDisabled() + + await isRestrictedCheckbox.uncheck() + await expect(isRestrictedCheckbox).not.toBeChecked() + + await waitForFormState('save') + await expect(restrictedField).toBeEnabled() + + await isRestrictedCheckbox.check() + await expect(isRestrictedCheckbox).toBeChecked() + + await waitForFormState('save') + + // Important: keep all the wait's, so that tests don't accidentally pass due to flashing of the field's readOnly state. + // While the new results are still coming in. + // The issue starts here, where saving a document without reload does not update the field's state from enabled to disabled, + // because the data object passed to the update access control function is incorrect. + await expect(restrictedField).toBeDisabled() + + await waitForFormState('reload') + + await expect(restrictedField).toBeDisabled() + + await payload.delete({ + collection: 'field-restricted-update-based-on-data', + }) + }) }) describe('rich text', () => { diff --git a/test/access-control/payload-types.ts b/test/access-control/payload-types.ts index 1a0409d62e8..ae07fee49b1 100644 --- a/test/access-control/payload-types.ts +++ b/test/access-control/payload-types.ts @@ -97,6 +97,7 @@ export interface Config { hooks: Hook; 'auth-collection': AuthCollection; 'read-restricted': ReadRestricted; + 'field-restricted-update-based-on-data': FieldRestrictedUpdateBasedOnDatum; 'payload-kv': PayloadKv; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; @@ -130,13 +131,14 @@ export interface Config { hooks: HooksSelect | HooksSelect; 'auth-collection': AuthCollectionSelect | AuthCollectionSelect; 'read-restricted': ReadRestrictedSelect | ReadRestrictedSelect; + 'field-restricted-update-based-on-data': FieldRestrictedUpdateBasedOnDataSelect | FieldRestrictedUpdateBasedOnDataSelect; 'payload-kv': PayloadKvSelect | PayloadKvSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { - defaultIDType: string; + defaultIDType: number; }; globals: { settings: Setting; @@ -237,7 +239,7 @@ export interface Titleblock { * via the `definition` "users". */ export interface User { - id: string; + id: number; roles?: ('admin' | 'user')[] | null; updatedAt: string; createdAt: string; @@ -262,7 +264,7 @@ export interface User { * via the `definition` "public-users". */ export interface PublicUser { - id: string; + id: number; updatedAt: string; createdAt: string; email: string; @@ -286,7 +288,7 @@ export interface PublicUser { * via the `definition` "posts". */ export interface Post { - id: string; + id: number; restrictedField?: string | null; group?: { restrictedGroupText?: string | null; @@ -301,14 +303,14 @@ export interface Post { * via the `definition` "unrestricted". */ export interface Unrestricted { - id: string; + id: number; name?: string | null; info?: { title?: string | null; description?: string | null; }; - userRestrictedDocs?: (string | UserRestrictedCollection)[] | null; - createNotUpdateDocs?: (string | CreateNotUpdateCollection)[] | null; + userRestrictedDocs?: (number | UserRestrictedCollection)[] | null; + createNotUpdateDocs?: (number | CreateNotUpdateCollection)[] | null; updatedAt: string; createdAt: string; } @@ -317,7 +319,7 @@ export interface Unrestricted { * via the `definition` "user-restricted-collection". */ export interface UserRestrictedCollection { - id: string; + id: number; name?: string | null; updatedAt: string; createdAt: string; @@ -327,7 +329,7 @@ export interface UserRestrictedCollection { * via the `definition` "create-not-update-collection". */ export interface CreateNotUpdateCollection { - id: string; + id: number; name?: string | null; updatedAt: string; createdAt: string; @@ -337,9 +339,9 @@ export interface CreateNotUpdateCollection { * via the `definition` "relation-restricted". */ export interface RelationRestricted { - id: string; + id: number; name?: string | null; - post?: (string | null) | Post; + post?: (number | null) | Post; updatedAt: string; createdAt: string; } @@ -348,7 +350,7 @@ export interface RelationRestricted { * via the `definition` "fully-restricted". */ export interface FullyRestricted { - id: string; + id: number; name?: string | null; updatedAt: string; createdAt: string; @@ -358,7 +360,7 @@ export interface FullyRestricted { * via the `definition` "read-only-collection". */ export interface ReadOnlyCollection { - id: string; + id: number; name?: string | null; updatedAt: string; createdAt: string; @@ -368,7 +370,7 @@ export interface ReadOnlyCollection { * via the `definition` "restricted-versions". */ export interface RestrictedVersion { - id: string; + id: number; name?: string | null; hidden?: boolean | null; updatedAt: string; @@ -379,7 +381,7 @@ export interface RestrictedVersion { * via the `definition` "restricted-versions-admin-panel". */ export interface RestrictedVersionsAdminPanel { - id: string; + id: number; name?: string | null; hidden?: boolean | null; updatedAt: string; @@ -390,7 +392,7 @@ export interface RestrictedVersionsAdminPanel { * via the `definition` "sibling-data". */ export interface SiblingDatum { - id: string; + id: number; array?: | { allowPublicReadability?: boolean | null; @@ -406,7 +408,7 @@ export interface SiblingDatum { * via the `definition` "rely-on-request-headers". */ export interface RelyOnRequestHeader { - id: string; + id: number; name?: string | null; updatedAt: string; createdAt: string; @@ -416,7 +418,7 @@ export interface RelyOnRequestHeader { * via the `definition` "doc-level-access". */ export interface DocLevelAccess { - id: string; + id: number; approvedForRemoval?: boolean | null; approvedTitle?: string | null; lockTitle?: boolean | null; @@ -428,7 +430,7 @@ export interface DocLevelAccess { * via the `definition` "hidden-fields". */ export interface HiddenField { - id: string; + id: number; title?: string | null; partiallyHiddenGroup?: { name?: string | null; @@ -451,7 +453,7 @@ export interface HiddenField { * via the `definition` "hidden-access". */ export interface HiddenAccess { - id: string; + id: number; title: string; hidden?: boolean | null; updatedAt: string; @@ -462,7 +464,7 @@ export interface HiddenAccess { * via the `definition` "hidden-access-count". */ export interface HiddenAccessCount { - id: string; + id: number; title: string; hidden?: boolean | null; updatedAt: string; @@ -473,7 +475,7 @@ export interface HiddenAccessCount { * via the `definition` "fields-and-top-access". */ export interface FieldsAndTopAccess { - id: string; + id: number; secret?: string | null; updatedAt: string; createdAt: string; @@ -484,7 +486,7 @@ export interface FieldsAndTopAccess { * via the `definition` "blocks-field-access". */ export interface BlocksFieldAccess { - id: string; + id: number; title: string; editableBlocks?: | { @@ -526,7 +528,7 @@ export interface BlocksFieldAccess { * via the `definition` "disabled". */ export interface Disabled { - id: string; + id: number; group?: { text?: string | null; }; @@ -548,7 +550,7 @@ export interface Disabled { * via the `definition` "rich-text". */ export interface RichText { - id: string; + id: number; blocks?: | { richText?: { @@ -579,7 +581,7 @@ export interface RichText { * via the `definition` "regression1". */ export interface Regression1 { - id: string; + id: number; group1?: { richText1?: { root: { @@ -744,7 +746,7 @@ export interface Regression1 { * via the `definition` "regression2". */ export interface Regression2 { - id: string; + id: number; group?: { richText1?: { root: { @@ -791,7 +793,7 @@ export interface Regression2 { * via the `definition` "hooks". */ export interface Hook { - id: string; + id: number; cannotMutateRequired: string; cannotMutateNotRequired?: string | null; canMutate?: string | null; @@ -803,7 +805,7 @@ export interface Hook { * via the `definition` "auth-collection". */ export interface AuthCollection { - id: string; + id: number; password?: string | null; roles?: ('admin' | 'user')[] | null; updatedAt: string; @@ -830,7 +832,7 @@ export interface AuthCollection { * via the `definition` "read-restricted". */ export interface ReadRestricted { - id: string; + id: number; restrictedTopLevel?: string | null; visibleTopLevel?: string | null; contactInfo?: { @@ -873,7 +875,7 @@ export interface ReadRestricted { visibleAdvanced?: string | null; restrictedAdvanced?: string | null; }; - unrestricted?: (string | null) | Unrestricted; + unrestricted?: (number | null) | Unrestricted; unrestrictedVirtualFieldName?: string | null; unrestrictedVirtualGroupInfo?: { title?: string | null; @@ -883,12 +885,24 @@ export interface ReadRestricted { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "field-restricted-update-based-on-data". + */ +export interface FieldRestrictedUpdateBasedOnDatum { + id: number; + restricted?: string | null; + doesNothing?: boolean | null; + isRestricted?: boolean | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-kv". */ export interface PayloadKv { - id: string; + id: number; key: string; data: | { @@ -905,125 +919,129 @@ export interface PayloadKv { * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: string; + id: number; document?: | ({ relationTo: 'users'; - value: string | User; + value: number | User; } | null) | ({ relationTo: 'public-users'; - value: string | PublicUser; + value: number | PublicUser; } | null) | ({ relationTo: 'posts'; - value: string | Post; + value: number | Post; } | null) | ({ relationTo: 'unrestricted'; - value: string | Unrestricted; + value: number | Unrestricted; } | null) | ({ relationTo: 'relation-restricted'; - value: string | RelationRestricted; + value: number | RelationRestricted; } | null) | ({ relationTo: 'fully-restricted'; - value: string | FullyRestricted; + value: number | FullyRestricted; } | null) | ({ relationTo: 'read-only-collection'; - value: string | ReadOnlyCollection; + value: number | ReadOnlyCollection; } | null) | ({ relationTo: 'user-restricted-collection'; - value: string | UserRestrictedCollection; + value: number | UserRestrictedCollection; } | null) | ({ relationTo: 'create-not-update-collection'; - value: string | CreateNotUpdateCollection; + value: number | CreateNotUpdateCollection; } | null) | ({ relationTo: 'restricted-versions'; - value: string | RestrictedVersion; + value: number | RestrictedVersion; } | null) | ({ relationTo: 'restricted-versions-admin-panel'; - value: string | RestrictedVersionsAdminPanel; + value: number | RestrictedVersionsAdminPanel; } | null) | ({ relationTo: 'sibling-data'; - value: string | SiblingDatum; + value: number | SiblingDatum; } | null) | ({ relationTo: 'rely-on-request-headers'; - value: string | RelyOnRequestHeader; + value: number | RelyOnRequestHeader; } | null) | ({ relationTo: 'doc-level-access'; - value: string | DocLevelAccess; + value: number | DocLevelAccess; } | null) | ({ relationTo: 'hidden-fields'; - value: string | HiddenField; + value: number | HiddenField; } | null) | ({ relationTo: 'hidden-access'; - value: string | HiddenAccess; + value: number | HiddenAccess; } | null) | ({ relationTo: 'hidden-access-count'; - value: string | HiddenAccessCount; + value: number | HiddenAccessCount; } | null) | ({ relationTo: 'fields-and-top-access'; - value: string | FieldsAndTopAccess; + value: number | FieldsAndTopAccess; } | null) | ({ relationTo: 'blocks-field-access'; - value: string | BlocksFieldAccess; + value: number | BlocksFieldAccess; } | null) | ({ relationTo: 'disabled'; - value: string | Disabled; + value: number | Disabled; } | null) | ({ relationTo: 'rich-text'; - value: string | RichText; + value: number | RichText; } | null) | ({ relationTo: 'regression1'; - value: string | Regression1; + value: number | Regression1; } | null) | ({ relationTo: 'regression2'; - value: string | Regression2; + value: number | Regression2; } | null) | ({ relationTo: 'hooks'; - value: string | Hook; + value: number | Hook; } | null) | ({ relationTo: 'auth-collection'; - value: string | AuthCollection; + value: number | AuthCollection; } | null) | ({ relationTo: 'read-restricted'; - value: string | ReadRestricted; + value: number | ReadRestricted; + } | null) + | ({ + relationTo: 'field-restricted-update-based-on-data'; + value: number | FieldRestrictedUpdateBasedOnDatum; } | null); globalSlug?: string | null; user: | { relationTo: 'users'; - value: string | User; + value: number | User; } | { relationTo: 'public-users'; - value: string | PublicUser; + value: number | PublicUser; } | { relationTo: 'auth-collection'; - value: string | AuthCollection; + value: number | AuthCollection; }; updatedAt: string; createdAt: string; @@ -1033,19 +1051,19 @@ export interface PayloadLockedDocument { * via the `definition` "payload-preferences". */ export interface PayloadPreference { - id: string; + id: number; user: | { relationTo: 'users'; - value: string | User; + value: number | User; } | { relationTo: 'public-users'; - value: string | PublicUser; + value: number | PublicUser; } | { relationTo: 'auth-collection'; - value: string | AuthCollection; + value: number | AuthCollection; }; key?: string | null; value?: @@ -1065,7 +1083,7 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: string; + id: number; name?: string | null; batch?: number | null; updatedAt: string; @@ -1593,6 +1611,17 @@ export interface ReadRestrictedSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "field-restricted-update-based-on-data_select". + */ +export interface FieldRestrictedUpdateBasedOnDataSelect { + restricted?: T; + doesNothing?: T; + isRestricted?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-kv_select". @@ -1638,7 +1667,7 @@ export interface PayloadMigrationsSelect { * via the `definition` "settings". */ export interface Setting { - id: string; + id: number; test?: boolean | null; updatedAt?: string | null; createdAt?: string | null; @@ -1648,7 +1677,7 @@ export interface Setting { * via the `definition` "test". */ export interface Test { - id: string; + id: number; updatedAt?: string | null; createdAt?: string | null; } @@ -1657,7 +1686,7 @@ export interface Test { * via the `definition` "read-only-global". */ export interface ReadOnlyGlobal { - id: string; + id: number; name?: string | null; updatedAt?: string | null; createdAt?: string | null; @@ -1667,7 +1696,7 @@ export interface ReadOnlyGlobal { * via the `definition` "user-restricted-global". */ export interface UserRestrictedGlobal { - id: string; + id: number; name?: string | null; updatedAt?: string | null; createdAt?: string | null; @@ -1677,7 +1706,7 @@ export interface UserRestrictedGlobal { * via the `definition` "read-not-update-global". */ export interface ReadNotUpdateGlobal { - id: string; + id: number; name?: string | null; updatedAt?: string | null; createdAt?: string | null; From 5da84e5b1e9cf5a2dbaa3436bb3ee27140d77248 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sat, 15 Nov 2025 15:35:43 -0800 Subject: [PATCH 02/42] improvements --- docs/access-control/overview.mdx | 10 ++++++---- packages/payload/src/auth/types.ts | 8 +++----- packages/payload/src/config/types.ts | 3 ++- packages/payload/src/utilities/getEntityPolicies.ts | 2 +- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/access-control/overview.mdx b/docs/access-control/overview.mdx index 8d18365dc29..25fa3d17e69 100644 --- a/docs/access-control/overview.mdx +++ b/docs/access-control/overview.mdx @@ -56,12 +56,14 @@ To accomplish this, Payload exposes the [Access Operation](../authentication/ope **Important:** When your access control functions are executed via the [Access - Operation](../authentication/operations#access), the `id` and `data` arguments - will be `undefined`. This is because Payload is executing your functions - without referencing a specific Document. + Operation](../authentication/operations#access), the `id`, `data`, `siblingData`, `blockData` and `doc` arguments + will be `undefined`. Additionally, `Where` queries returned from access control functions will not be run - we'll assume the user does not have access instead. + +This is because Payload is executing your functions without referencing a specific Document. + -If you use `id` or `data` within your access control functions, make sure to check that they are defined first. If they are not, then you can assume that your Access Control is being executed via the Access Operation to determine solely what the user can do within the Admin Panel. +If you use `id`, `data`, `siblingData`, `blockData` and `doc` within your access control functions, make sure to check that they are defined first. If they are not, then you can assume that your Access Control is being executed via the Access Operation to determine solely what the user can do within the Admin Panel. ## Locale Specific Access Control diff --git a/packages/payload/src/auth/types.ts b/packages/payload/src/auth/types.ts index 10281630329..82af2714264 100644 --- a/packages/payload/src/auth/types.ts +++ b/packages/payload/src/auth/types.ts @@ -28,11 +28,9 @@ export type SanitizedBlockPermissions = } | true -export type BlocksPermissions = - | { - [blockSlug: string]: BlockPermissions - } - | true +export type BlocksPermissions = { + [blockSlug: string]: BlockPermissions +} export type SanitizedBlocksPermissions = | { diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 0a429c234cf..c2f87a07a6f 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -43,6 +43,7 @@ import type { RootFoldersConfiguration } from '../folders/types.js' import type { GlobalConfig, Globals, SanitizedGlobalConfig } from '../globals/config/types.js' import type { Block, + DefaultDocumentIDType, FlattenedBlock, JobsConfig, KVAdapterResult, @@ -314,7 +315,7 @@ export type AccessArgs = { */ data?: TData /** ID of the resource being accessed */ - id?: number | string + id?: DefaultDocumentIDType /** If true, the request is for a static file */ isReadingStaticFile?: boolean /** The original request that requires an access check */ diff --git a/packages/payload/src/utilities/getEntityPolicies.ts b/packages/payload/src/utilities/getEntityPolicies.ts index 1daaad44f2e..3b2b95fd9ec 100644 --- a/packages/payload/src/utilities/getEntityPolicies.ts +++ b/packages/payload/src/utilities/getEntityPolicies.ts @@ -34,7 +34,7 @@ type CreateAccessPromise = (args: { type EntityDoc = JsonObject | TypeWithID /** - * Build up permissions object for an entity (collection or global) + * Build up permissions object for an entity (collection or global). SCHEMA Permissions - disregards siblingData */ export async function getEntityPolicies(args: T): Promise> { const { id, type, blockPolicies, entity, operations, req } = args From 2976c2eab4785331dc67324b1dcba011e1a24bfb Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sat, 15 Nov 2025 15:36:06 -0800 Subject: [PATCH 03/42] perf: rewrite getEntityPolicies (permissions object calculation) --- .../getEntityPermissions/entityDocExists.ts | 63 ++++ .../getEntityPermissions.ts | 240 +++++++++++++++ .../populateFieldPermissions.ts | 290 ++++++++++++++++++ 3 files changed, 593 insertions(+) create mode 100644 packages/payload/src/utilities/getEntityPermissions/entityDocExists.ts create mode 100644 packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts create mode 100644 packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts diff --git a/packages/payload/src/utilities/getEntityPermissions/entityDocExists.ts b/packages/payload/src/utilities/getEntityPermissions/entityDocExists.ts new file mode 100644 index 00000000000..cf173efae79 --- /dev/null +++ b/packages/payload/src/utilities/getEntityPermissions/entityDocExists.ts @@ -0,0 +1,63 @@ +import { + type AllOperations, + combineQueries, + type DefaultDocumentIDType, + type PayloadRequest, + type Where, +} from '../../index.js' + +/** + * Returns whether or not the entity doc exists based on the where query. + */ +export async function entityDocExists({ + id, + slug, + entityType, + locale, + operation, + req, + where, +}: { + entityType: 'collection' | 'global' + id?: DefaultDocumentIDType + locale?: string + operation?: AllOperations + req: PayloadRequest + slug: string + where: Where +}): Promise { + if (entityType === 'global') { + // TODO: Write test (should be broken in prev version since we just find without where?), + // perf optimize (returning false or countGlobal or db.globalExists?) + const global = await req.payload.db.findGlobal({ + slug, + locale, + req, + where: combineQueries(where, { id: { equals: id } }), + }) + return Boolean(global) + } + + if (entityType === 'collection' && id) { + if (operation === 'readVersions') { + const count = await req.payload.db.countVersions({ + collection: slug, + locale, + req, + where: combineQueries(where, { parent: { equals: id } }), + }) + return count.totalDocs > 0 + } + + const count = await req.payload.db.count({ + collection: slug, + locale, + req, + where: combineQueries(where, { id: { equals: id } }), + }) + + return count.totalDocs > 0 + } + + return false +} diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts new file mode 100644 index 00000000000..9a505c166e5 --- /dev/null +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -0,0 +1,240 @@ +import type { + BlockPermissions, + CollectionPermission, + FieldsPermissions, + GlobalPermission, +} from '../../auth/types.js' +import type { SanitizedCollectionConfig, TypeWithID } from '../../collections/config/types.js' +import type { Access } from '../../config/types.js' +import type { SanitizedGlobalConfig } from '../../globals/config/types.js' +import type { BlockSlug, DefaultDocumentIDType } from '../../index.js' +import type { AllOperations, JsonObject, PayloadRequest } from '../../types/index.js' + +import { entityDocExists } from './entityDocExists.js' +import { populateFieldPermissions } from './populateFieldPermissions.js' + +export type BlockReferencesPermissions = Record< + BlockSlug, + BlockPermissions | Promise +> + +export type EntityDoc = JsonObject | TypeWithID + +type ReturnType = TEntityType extends 'global' + ? GlobalPermission + : CollectionPermission + +type Args = { + blockReferencesPermissions: BlockReferencesPermissions + entity: TEntityType extends 'collection' ? SanitizedCollectionConfig : SanitizedGlobalConfig + entityType: TEntityType + /** + * Operations to check access for + */ + operations: AllOperations[] + req: PayloadRequest +} & ( + | { + fetchData: false + id?: never + } + | { + fetchData: true + id: TEntityType extends 'collection' ? DefaultDocumentIDType : undefined + } +) + +const topLevelCollectionPermissions = ['create', 'delete', 'read', 'readVersions', 'update'] +const topLevelGlobalPermissions = ['read', 'readVersions', 'update'] + +/** + * Build up permissions object for an entity (collection or global). + * This is not run during any update and reflects the current state of the entity data => doc and data is the same. + * + * When `fetchData` is false: + * - returned `Where` are not run and evaluated as "does not have permission". + * - If req.data is passed: `data` and `doc` is passed to access functions. + * - If req.data is not passed: `data` and `doc` is not passed to access functions. + * + * When `fetchData` is true: + * - `Where` are run and evaluated as "has permission" or "does not have permission". + * - `data` and `doc` are always passed to access functions. + * - Error is thrown if `entityType` is 'collection' and `id` is not passed. + * + * In both cases: + * We cannot include siblingData or blockData here, as we do not have siblingData available once we reach block or array + * rows, as we're calculating schema permissions, which do not include individual rows. + * For consistency, it's thus better to never include the siblingData and blockData + */ +export async function getEntityPermission( + args: Args, +): Promise> { + const { id, blockReferencesPermissions, entity, entityType, fetchData, operations, req } = args + const { data: _data, locale: _locale, user } = req + + const locale = _locale ? _locale : undefined + + const hasData = _data && Object.keys(_data).length > 0 + const data: JsonObject | Promise | undefined = ( + hasData + ? _data + : fetchData + ? (async () => { + if (entityType === 'global') { + return req.payload.findGlobal({ + slug: entity.slug, + depth: 0, + fallbackLocale: null, + locale, + overrideAccess: true, + req, + }) + } + + if (entityType === 'collection') { + if (!id) { + throw new Error('ID is required when fetching data for a collection') + } + + return req.payload.findByID({ + id, + collection: entity.slug, + depth: 0, + fallbackLocale: null, + locale, + overrideAccess: true, + req, + trash: true, + }) + } + })() + : undefined + ) as JsonObject | Promise + + const isLoggedIn = !!user + + const fieldsPermissions: FieldsPermissions = {} + + const entityPermissions: ReturnType = { + fields: fieldsPermissions, + } as ReturnType + + const promises: Promise[] = [] + + for (const _operation of operations) { + const operation = _operation as keyof typeof entity.access + const accessFunction = entity.access[operation] + + if ( + (entityType === 'collection' && topLevelCollectionPermissions.includes(operation)) || + (entityType === 'global' && topLevelGlobalPermissions.includes(operation)) + ) { + if (typeof accessFunction === 'function') { + promises.push( + createEntityAccessPromise({ + id, + slug: entity.slug, + access: accessFunction, + data, + entityType, + locale, + operation, + permissionsObject: entityPermissions, + req, + }), + ) + } else { + entityPermissions[operation] = { + permission: isLoggedIn, + } + } + } + } + + const resolvedData = await data + + populateFieldPermissions({ + blockReferencesPermissions, + data: resolvedData, + fields: entity.fields, + operations, + parentPermissionsObject: entityPermissions, + permissionsObject: fieldsPermissions, + promises, + req, + }) + + /** + * Await all promises in parallel. + * A promise can add more promises to the promises array (group of fields calls populateFieldPermissions again in their own promise), which will not be + * awaited in the first run. + * This is why we need to loop again to process the new promises, until there are no more promises left. + */ + let iterations = 0 + while (promises.length > 0) { + const currentPromises = promises.splice(0, promises.length) + + await Promise.all(currentPromises) + + iterations++ + if (iterations >= 100) { + throw new Error('Infinite getEntityPermissions promise loop detected.') + } + } + + return entityPermissions +} + +type CreateEntityAccessPromise = (args: { + access: Access + data: JsonObject | Promise | undefined + disableWhere?: boolean + entityType: 'collection' | 'global' + id?: DefaultDocumentIDType + locale?: string + operation: Extract + permissionsObject: CollectionPermission | GlobalPermission + req: PayloadRequest + slug: string +}) => Promise + +const createEntityAccessPromise: CreateEntityAccessPromise = async ({ + id, + slug, + access, + data, + disableWhere = false, + entityType, + locale, + operation, + permissionsObject, + req, +}) => { + // Await data - if it's a Promise it resolves, if not it returns immediately + const resolvedData = await data + + const accessResult = await access({ id, data: resolvedData, req }) + + // Where query was returned from access function => check if document is returned when querying with where + if (typeof accessResult === 'object' && !disableWhere) { + permissionsObject[operation] = { + permission: + id || entityType === 'global' + ? await entityDocExists({ + id, + slug, + entityType, + locale, + operation, + req, + where: accessResult, + }) + : true, + where: accessResult, + } + } else if (permissionsObject[operation]?.permission !== false) { + permissionsObject[operation] = { + permission: !!accessResult, + } + } +} diff --git a/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts new file mode 100644 index 00000000000..d6922c2ddc8 --- /dev/null +++ b/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts @@ -0,0 +1,290 @@ +import type { + BlockPermissions, + BlocksPermissions, + CollectionPermission, + FieldPermissions, + FieldsPermissions, + GlobalPermission, + Permission, +} from '../../auth/types.js' +import type { DefaultDocumentIDType } from '../../index.js' +import type { AllOperations, JsonObject, PayloadRequest } from '../../types/index.js' +import type { BlockReferencesPermissions } from './getEntityPermissions.js' + +import { type Field, tabHasName } from '../../fields/config/types.js' + +const isThenable = (value: unknown): value is Promise => + value != null && typeof (value as { then?: unknown }).then === 'function' + +/** + * Build up permissions object and run access functions for each field of an entity + */ +export const populateFieldPermissions = ({ + id, + blockReferencesPermissions, + data, + fields, + operations, + parentPermissionsObject, + permissionsObject, + promises, + req, +}: { + blockReferencesPermissions: BlockReferencesPermissions + data: JsonObject | undefined + fields: Field[] + id?: DefaultDocumentIDType + /** + * Operations to check access for + */ + operations: AllOperations[] + parentPermissionsObject: CollectionPermission | FieldPermissions | GlobalPermission + permissionsObject: FieldsPermissions + promises: Promise[] + req: PayloadRequest +}): void => { + for (const field of fields) { + for (const operation of operations) { + const parentPermissionForOperation = ( + parentPermissionsObject[operation as keyof typeof parentPermissionsObject] as Permission + )?.permission + + // const operation = _operation as keyof Omit + + // Fields don't have all operations of a collection + if (operation === 'delete' || operation === 'readVersions' || operation === 'unlock') { + continue + } + + if ('name' in field && field.name) { + if (!permissionsObject[field.name]) { + permissionsObject[field.name] = {} as FieldPermissions + } + const fieldPermissions: FieldPermissions = permissionsObject[field.name]! + + if ('access' in field && field.access && typeof field.access[operation] === 'function') { + const accessResult = field.access[operation]({ + id, + data, + doc: data, + req, + // We cannot include siblingData or blockData here, as we do not have siblingData/blockData available once we reach block or array + // rows, as we're calculating schema permissions, which do not include individual rows. + // For consistency, it's thus better to never include the siblingData and blockData + }) + if (isThenable(accessResult)) { + promises.push( + accessResult.then((result) => { + fieldPermissions[operation] = { + permission: Boolean(result), + } + }), + ) + } else { + fieldPermissions[operation] = { + permission: Boolean(accessResult), + } + } + } else { + fieldPermissions[operation] = { + permission: parentPermissionForOperation, + } + } + + if ('fields' in field && field.fields) { + if (!fieldPermissions.fields) { + fieldPermissions.fields = {} + } + + fieldPermissions.fields = {} as FieldsPermissions + + // For correct calculation of `parentPermissionForOperation`, the parentPermissionsObject must be completely + // calculated and awaited before calculating field permissions of nested fields. + // TODO + populateFieldPermissions({ + id, + blockReferencesPermissions, + data, + fields: field.fields, + operations, + parentPermissionsObject: fieldPermissions, + permissionsObject: fieldPermissions.fields, + promises, + req, + }) + } + + if ( + ('blocks' in field && field.blocks?.length) || + ('blockReferences' in field && field.blockReferences?.length) + ) { + if (!permissionsObject[field.name]?.blocks) { + fieldPermissions.blocks = {} + } + const blocksPermissions: BlocksPermissions = fieldPermissions.blocks! + + for (const _block of field.blockReferences ?? field.blocks) { + const block = typeof _block === 'string' ? req.payload.blocks[_block] : _block + + // Skip if block doesn't exist (invalid block reference) + if (!block) { + return + } + + if (!blocksPermissions[block.slug]) { + blocksPermissions[block.slug] = {} as BlockPermissions + } + + if (typeof _block === 'string') { + const blockReferencePermissions = blockReferencesPermissions[_block] + if (blockReferencePermissions) { + if (isThenable(blockReferencePermissions)) { + // Earlier access to this block is still pending, so await it instead of re-running executeFieldPolicies + blocksPermissions[block.slug] = await blockReferencePermissions + } else { + // It's already a resolved policy object + blocksPermissions[block.slug] = blockReferencePermissions + } + return + } else { + // We have not seen this block slug yet. Immediately create a promise + // so that any parallel calls will just await this same promise + // instead of re-running executeFieldPolicies. + blocksPermissions[block.slug] = (async () => { + // If the block doesn't exist yet in our permissionsObject, initialize it + if (!fieldPermissions.blocks?.[block.slug]) { + // Use field-level permission instead of parentPermissionForOperation for blocks + // This ensures that if the field has access control, it applies to all blocks in the field + const fieldPermission = + fieldPermissions[operation]?.permission ?? parentPermissionForOperation + + fieldPermissions.blocks[block.slug] = { + fields: {}, + [operation]: { permission: fieldPermission }, + } + } else if (!fieldPermissions.blocks[block.slug][operation]) { + // Use field-level permission for consistency + const fieldPermission = + fieldPermissions[operation]?.permission ?? parentPermissionForOperation + + fieldPermissions.blocks[block.slug][operation] = { + permission: fieldPermission, + } + } + + await executeFieldPolicies({ + blockPermissions, + createAccessPromise, + fields: block.fields, + operation, + parentPermissionForOperation: + fieldPermissions[operation]?.permission ?? parentPermissionForOperation, + payload, + permissionsObject: fieldPermissions.blocks[block.slug], + }) + + return fieldPermissions.blocks[block.slug] + })() + + fieldPermissions.blocks[block.slug] = await blockPermissions[_block] + blockPermissions[_block] = fieldPermissions.blocks[block.slug] + return + } + } + + if (!fieldPermissions.blocks?.[block.slug]) { + // Use field-level permission instead of parentPermissionForOperation for blocks + const fieldPermission = + fieldPermissions[operation]?.permission ?? parentPermissionForOperation + + fieldPermissions.blocks[block.slug] = { + fields: {}, + [operation]: { permission: fieldPermission }, + } + } else if (!fieldPermissions.blocks[block.slug][operation]) { + // Use field-level permission for consistency + const fieldPermission = + fieldPermissions[operation]?.permission ?? parentPermissionForOperation + + fieldPermissions.blocks[block.slug][operation] = { + permission: fieldPermission, + } + } + + // For correct calculation of `parentPermissionForOperation`, the parentPermissionsObject must be completely + // calculated and awaited before calculating field permissions of nested fields. + // TODO + await executeFieldPolicies({ + blockPermissions, + createAccessPromise, + fields: block.fields, + operation, + parentPermissionForOperation: + fieldPermissions[operation]?.permission ?? parentPermissionForOperation, + payload, + permissionsObject: fieldPermissions.blocks[block.slug], + }) + } + } + } else if ('fields' in field && field.fields) { + // Field does not have a name => same parentPermissionsObject => no need to await current level + populateFieldPermissions({ + id, + blockReferencesPermissions, + data, + fields: field.fields, + operations, + // Field does not have a name here => use parent permissions object + parentPermissionsObject, + permissionsObject, + promises, + req, + }) + } else if (field.type === 'tabs') { + for (const tab of field.tabs) { + if (tabHasName(tab)) { + const tabPermissions: FieldPermissions | undefined = permissionsObject[tab.name] + + if (!tabPermissions) { + permissionsObject[tab.name] = { + fields: {}, + [operation]: { permission: parentPermissionForOperation }, + } as FieldPermissions + } else if (!tabPermissions[operation]) { + permissionsObject[tab.name]![operation] = { permission: parentPermissionForOperation } + } + + // For correct calculation of `parentPermissionForOperation`, the parentPermissionsObject must be completely + // calculated and awaited before calculating field permissions of nested fields. + // TODO + populateFieldPermissions({ + id, + blockReferencesPermissions, + data, + fields: tab.fields, + operations, + parentPermissionsObject: tabPermissions!, + permissionsObject: tabPermissions!.fields!, + promises, + req, + }) + } else { + // Tab does not have a name => same parentPermissionsObject => no need to await current level + populateFieldPermissions({ + id, + blockReferencesPermissions, + data, + fields: tab.fields, + operations, + // Tab does not have a name here => use parent permissions object + parentPermissionsObject, + permissionsObject, + promises, + req, + }) + } + } + } + } + } +} From 005dd5785c6507f3cc621983b3186e41e3725e69 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sat, 15 Nov 2025 16:11:43 -0800 Subject: [PATCH 04/42] use new version --- packages/payload/src/auth/getAccessResults.ts | 22 +-- .../src/collections/operations/docAccess.ts | 9 +- .../queryValidation/validateSearchParams.ts | 18 +- .../src/globals/operations/docAccess.ts | 10 +- .../getEntityPermissions.ts | 30 ++-- .../populateFieldPermissions.ts | 159 ++++++++++-------- 6 files changed, 139 insertions(+), 109 deletions(-) diff --git a/packages/payload/src/auth/getAccessResults.ts b/packages/payload/src/auth/getAccessResults.ts index f5a2c3f33fe..39af581b976 100644 --- a/packages/payload/src/auth/getAccessResults.ts +++ b/packages/payload/src/auth/getAccessResults.ts @@ -1,7 +1,7 @@ import type { AllOperations, PayloadRequest } from '../types/index.js' import type { Permissions, SanitizedPermissions } from './types.js' -import { getEntityPolicies } from '../utilities/getEntityPolicies.js' +import { getEntityPermissions } from '../utilities/getEntityPermissions/getEntityPermissions.js' import { sanitizePermissions } from '../utilities/sanitizePermissions.js' type GetAccessResultsArgs = { @@ -27,7 +27,7 @@ export async function getAccessResults({ } else { results.canAccessAdmin = false } - const blockPolicies = {} + const blockReferencesPermissions = {} await Promise.all( payload.config.collections.map(async (collection) => { @@ -45,14 +45,15 @@ export async function getAccessResults({ collectionOperations.push('readVersions') } - const collectionPolicy = await getEntityPolicies({ - type: 'collection', - blockPolicies, + const collectionPermissions = await getEntityPermissions({ + blockReferencesPermissions, entity: collection, + entityType: 'collection', + fetchData: false, operations: collectionOperations, req, }) - results.collections![collection.slug] = collectionPolicy + results.collections![collection.slug] = collectionPermissions }), ) @@ -64,14 +65,15 @@ export async function getAccessResults({ globalOperations.push('readVersions') } - const globalPolicy = await getEntityPolicies({ - type: 'global', - blockPolicies, + const globalPermissions = await getEntityPermissions({ + blockReferencesPermissions, entity: global, + entityType: 'global', + fetchData: false, operations: globalOperations, req, }) - results.globals![global.slug] = globalPolicy + results.globals![global.slug] = globalPermissions }), ) diff --git a/packages/payload/src/collections/operations/docAccess.ts b/packages/payload/src/collections/operations/docAccess.ts index 29fa0893371..ef8deea7b63 100644 --- a/packages/payload/src/collections/operations/docAccess.ts +++ b/packages/payload/src/collections/operations/docAccess.ts @@ -2,7 +2,7 @@ import type { SanitizedCollectionPermission } from '../../auth/index.js' import type { AllOperations, PayloadRequest } from '../../types/index.js' import type { Collection } from '../config/types.js' -import { getEntityPolicies } from '../../utilities/getEntityPolicies.js' +import { getEntityPermissions } from '../../utilities/getEntityPermissions/getEntityPermissions.js' import { killTransaction } from '../../utilities/killTransaction.js' import { sanitizePermissions } from '../../utilities/sanitizePermissions.js' @@ -36,11 +36,12 @@ export async function docAccessOperation(args: Arguments): Promise( +export async function getEntityPermissions( args: Args, ): Promise> { const { id, blockReferencesPermissions, entity, entityType, fetchData, operations, req } = args @@ -137,6 +137,7 @@ export async function getEntityPermission | undefined disableWhere?: boolean entityType: 'collection' | 'global' + fetchData: boolean id?: DefaultDocumentIDType locale?: string operation: Extract @@ -205,6 +207,7 @@ const createEntityAccessPromise: CreateEntityAccessPromise = async ({ data, disableWhere = false, entityType, + fetchData, locale, operation, permissionsObject, @@ -218,18 +221,17 @@ const createEntityAccessPromise: CreateEntityAccessPromise = async ({ // Where query was returned from access function => check if document is returned when querying with where if (typeof accessResult === 'object' && !disableWhere) { permissionsObject[operation] = { - permission: - id || entityType === 'global' - ? await entityDocExists({ - id, - slug, - entityType, - locale, - operation, - req, - where: accessResult, - }) - : true, + permission: fetchData + ? await entityDocExists({ + id, + slug, + entityType, + locale, + operation, + req, + where: accessResult, + }) + : false, where: accessResult, } } else if (permissionsObject[operation]?.permission !== false) { diff --git a/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts index d6922c2ddc8..334a8fc9096 100644 --- a/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts @@ -19,7 +19,7 @@ const isThenable = (value: unknown): value is Promise => /** * Build up permissions object and run access functions for each field of an entity */ -export const populateFieldPermissions = ({ +export const populateFieldPermissions = async ({ id, blockReferencesPermissions, data, @@ -42,7 +42,7 @@ export const populateFieldPermissions = ({ permissionsObject: FieldsPermissions promises: Promise[] req: PayloadRequest -}): void => { +}): Promise => { for (const field of fields) { for (const operation of operations) { const parentPermissionForOperation = ( @@ -62,6 +62,9 @@ export const populateFieldPermissions = ({ } const fieldPermissions: FieldPermissions = permissionsObject[field.name]! + // Track if we need to await before processing nested fields + let pendingAccessPromise: Promise | undefined + if ('access' in field && field.access && typeof field.access[operation] === 'function') { const accessResult = field.access[operation]({ id, @@ -73,13 +76,19 @@ export const populateFieldPermissions = ({ // For consistency, it's thus better to never include the siblingData and blockData }) if (isThenable(accessResult)) { - promises.push( - accessResult.then((result) => { - fieldPermissions[operation] = { - permission: Boolean(result), - } - }), - ) + const promise = accessResult.then((result) => { + fieldPermissions[operation] = { + permission: Boolean(result), + } + }) + + // If this field has nested fields, we'll await this promise before recursing + // Otherwise, add it to the promises array for parallel processing + if ('fields' in field && field.fields) { + pendingAccessPromise = promise + } else { + promises.push(promise) + } } else { fieldPermissions[operation] = { permission: Boolean(accessResult), @@ -100,8 +109,11 @@ export const populateFieldPermissions = ({ // For correct calculation of `parentPermissionForOperation`, the parentPermissionsObject must be completely // calculated and awaited before calculating field permissions of nested fields. - // TODO - populateFieldPermissions({ + if (pendingAccessPromise) { + await pendingAccessPromise + } + + await populateFieldPermissions({ id, blockReferencesPermissions, data, @@ -118,17 +130,17 @@ export const populateFieldPermissions = ({ ('blocks' in field && field.blocks?.length) || ('blockReferences' in field && field.blockReferences?.length) ) { - if (!permissionsObject[field.name]?.blocks) { + if (!fieldPermissions.blocks) { fieldPermissions.blocks = {} } - const blocksPermissions: BlocksPermissions = fieldPermissions.blocks! + const blocksPermissions: BlocksPermissions = fieldPermissions.blocks for (const _block of field.blockReferences ?? field.blocks) { const block = typeof _block === 'string' ? req.payload.blocks[_block] : _block // Skip if block doesn't exist (invalid block reference) if (!block) { - return + continue } if (!blocksPermissions[block.slug]) { @@ -139,96 +151,106 @@ export const populateFieldPermissions = ({ const blockReferencePermissions = blockReferencesPermissions[_block] if (blockReferencePermissions) { if (isThenable(blockReferencePermissions)) { - // Earlier access to this block is still pending, so await it instead of re-running executeFieldPolicies + // Earlier access to this block is still pending, so await it instead of re-running populateFieldPermissions blocksPermissions[block.slug] = await blockReferencePermissions } else { // It's already a resolved policy object blocksPermissions[block.slug] = blockReferencePermissions } - return + continue } else { // We have not seen this block slug yet. Immediately create a promise // so that any parallel calls will just await this same promise - // instead of re-running executeFieldPolicies. - blocksPermissions[block.slug] = (async () => { + // instead of re-running populateFieldPermissions. + const blockPromise = (async (): Promise => { // If the block doesn't exist yet in our permissionsObject, initialize it - if (!fieldPermissions.blocks?.[block.slug]) { - // Use field-level permission instead of parentPermissionForOperation for blocks - // This ensures that if the field has access control, it applies to all blocks in the field - const fieldPermission = - fieldPermissions[operation]?.permission ?? parentPermissionForOperation + // Use field-level permission instead of parentPermissionForOperation for blocks + // This ensures that if the field has access control, it applies to all blocks in the field + const fieldPermission = + fieldPermissions[operation]?.permission ?? parentPermissionForOperation - fieldPermissions.blocks[block.slug] = { + if (!blocksPermissions[block.slug]) { + blocksPermissions[block.slug] = { fields: {}, [operation]: { permission: fieldPermission }, - } - } else if (!fieldPermissions.blocks[block.slug][operation]) { - // Use field-level permission for consistency - const fieldPermission = - fieldPermissions[operation]?.permission ?? parentPermissionForOperation - - fieldPermissions.blocks[block.slug][operation] = { + } as BlockPermissions + } else if (!blocksPermissions[block.slug]?.[operation]) { + blocksPermissions[block.slug]![operation] = { permission: fieldPermission, } } - await executeFieldPolicies({ - blockPermissions, - createAccessPromise, + const blockPermission = blocksPermissions[block.slug]! + if (!blockPermission.fields) { + blockPermission.fields = {} + } + + await populateFieldPermissions({ + id, + blockReferencesPermissions, + data, fields: block.fields, - operation, - parentPermissionForOperation: - fieldPermissions[operation]?.permission ?? parentPermissionForOperation, - payload, - permissionsObject: fieldPermissions.blocks[block.slug], + operations, + parentPermissionsObject: blockPermission, + permissionsObject: blockPermission.fields, + promises, + req, }) - return fieldPermissions.blocks[block.slug] + return blockPermission })() - fieldPermissions.blocks[block.slug] = await blockPermissions[_block] - blockPermissions[_block] = fieldPermissions.blocks[block.slug] - return + blockReferencesPermissions[_block] = blockPromise + blocksPermissions[block.slug] = await blockPromise + continue } } - if (!fieldPermissions.blocks?.[block.slug]) { - // Use field-level permission instead of parentPermissionForOperation for blocks - const fieldPermission = - fieldPermissions[operation]?.permission ?? parentPermissionForOperation + // Use field-level permission instead of parentPermissionForOperation for blocks + const fieldPermission = + fieldPermissions[operation]?.permission ?? parentPermissionForOperation - fieldPermissions.blocks[block.slug] = { + if (!blocksPermissions[block.slug]) { + blocksPermissions[block.slug] = { fields: {}, [operation]: { permission: fieldPermission }, - } - } else if (!fieldPermissions.blocks[block.slug][operation]) { - // Use field-level permission for consistency - const fieldPermission = - fieldPermissions[operation]?.permission ?? parentPermissionForOperation + } as BlockPermissions + } + + const blockPermission = blocksPermissions[block.slug] + if (!blockPermission) { + // Should never happen since we just set it above, but TypeScript needs the check + continue + } - fieldPermissions.blocks[block.slug][operation] = { + if (!blockPermission[operation]) { + blockPermission[operation] = { permission: fieldPermission, } } + if (!blockPermission.fields) { + blockPermission.fields = {} + } + // For correct calculation of `parentPermissionForOperation`, the parentPermissionsObject must be completely // calculated and awaited before calculating field permissions of nested fields. - // TODO - await executeFieldPolicies({ - blockPermissions, - createAccessPromise, + await populateFieldPermissions({ + id, + blockReferencesPermissions, + data, fields: block.fields, - operation, - parentPermissionForOperation: - fieldPermissions[operation]?.permission ?? parentPermissionForOperation, - payload, - permissionsObject: fieldPermissions.blocks[block.slug], + operations, + parentPermissionsObject: blockPermission, + permissionsObject: blockPermission.fields, + promises, + req, }) } } } else if ('fields' in field && field.fields) { // Field does not have a name => same parentPermissionsObject => no need to await current level - populateFieldPermissions({ + await populateFieldPermissions({ id, blockReferencesPermissions, data, @@ -254,10 +276,9 @@ export const populateFieldPermissions = ({ permissionsObject[tab.name]![operation] = { permission: parentPermissionForOperation } } - // For correct calculation of `parentPermissionForOperation`, the parentPermissionsObject must be completely - // calculated and awaited before calculating field permissions of nested fields. - // TODO - populateFieldPermissions({ + // For tabs with names, we don't have async access functions on the tab itself, + // so no need to await before recursing. The permission is set synchronously above. + await populateFieldPermissions({ id, blockReferencesPermissions, data, @@ -270,7 +291,7 @@ export const populateFieldPermissions = ({ }) } else { // Tab does not have a name => same parentPermissionsObject => no need to await current level - populateFieldPermissions({ + await populateFieldPermissions({ id, blockReferencesPermissions, data, From 867d1e446a06e241f0e2b028657256e8890195eb Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sat, 15 Nov 2025 16:16:52 -0800 Subject: [PATCH 05/42] fix --- .../populateFieldPermissions.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts index 334a8fc9096..983388a5ddd 100644 --- a/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts @@ -105,8 +105,6 @@ export const populateFieldPermissions = async ({ fieldPermissions.fields = {} } - fieldPermissions.fields = {} as FieldsPermissions - // For correct calculation of `parentPermissionForOperation`, the parentPermissionsObject must be completely // calculated and awaited before calculating field permissions of nested fields. if (pendingAccessPromise) { @@ -265,17 +263,21 @@ export const populateFieldPermissions = async ({ } else if (field.type === 'tabs') { for (const tab of field.tabs) { if (tabHasName(tab)) { - const tabPermissions: FieldPermissions | undefined = permissionsObject[tab.name] - - if (!tabPermissions) { + if (!permissionsObject[tab.name]) { permissionsObject[tab.name] = { fields: {}, [operation]: { permission: parentPermissionForOperation }, } as FieldPermissions - } else if (!tabPermissions[operation]) { + } else if (!permissionsObject[tab.name]![operation]) { permissionsObject[tab.name]![operation] = { permission: parentPermissionForOperation } } + const tabPermissions: FieldPermissions = permissionsObject[tab.name]! + + if (!tabPermissions.fields) { + tabPermissions.fields = {} + } + // For tabs with names, we don't have async access functions on the tab itself, // so no need to await before recursing. The permission is set synchronously above. await populateFieldPermissions({ @@ -284,8 +286,8 @@ export const populateFieldPermissions = async ({ data, fields: tab.fields, operations, - parentPermissionsObject: tabPermissions!, - permissionsObject: tabPermissions!.fields!, + parentPermissionsObject: tabPermissions, + permissionsObject: tabPermissions.fields, promises, req, }) From 845f7b893901b1cbbdcc4dd5876c78965c71f1fa Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sat, 15 Nov 2025 16:38:06 -0800 Subject: [PATCH 06/42] debug --- packages/payload/src/utilities/sanitizePermissions.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/payload/src/utilities/sanitizePermissions.ts b/packages/payload/src/utilities/sanitizePermissions.ts index e5d06cec0eb..c35e2c69514 100644 --- a/packages/payload/src/utilities/sanitizePermissions.ts +++ b/packages/payload/src/utilities/sanitizePermissions.ts @@ -135,6 +135,8 @@ function checkAndSanitizePermissions( continue } } else { + // eslint-disable-next-line no-console + console.error('Unexpected object in fields permissions', data, 'key:', key) throw new Error('Unexpected object in fields permissions') } } else if (data[key] !== true) { From 4e3a8e3a6a47f427250a7300321791f1e3d5f430 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sun, 16 Nov 2025 19:38:40 -0800 Subject: [PATCH 07/42] fix parallelism --- .../getEntityPermissions.ts | 7 +- .../populateFieldPermissions.ts | 351 ++++++++++-------- 2 files changed, 195 insertions(+), 163 deletions(-) diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts index 68003328df7..e400b60c0a4 100644 --- a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -119,6 +119,7 @@ export async function getEntityPermissions + const entityAccessPromises: Promise[] = [] const promises: Promise[] = [] for (const _operation of operations) { @@ -130,7 +131,7 @@ export async function getEntityPermissions => { for (const field of fields) { + // Track pending access promises for this field across all operations + const fieldAccessPromises: Promise[] = [] + + // First pass: Set up permissions for all operations for (const operation of operations) { const parentPermissionForOperation = ( parentPermissionsObject[operation as keyof typeof parentPermissionsObject] as Permission )?.permission - // const operation = _operation as keyof Omit - // Fields don't have all operations of a collection if (operation === 'delete' || operation === 'readVersions' || operation === 'unlock') { continue @@ -62,9 +64,6 @@ export const populateFieldPermissions = async ({ } const fieldPermissions: FieldPermissions = permissionsObject[field.name]! - // Track if we need to await before processing nested fields - let pendingAccessPromise: Promise | undefined - if ('access' in field && field.access && typeof field.access[operation] === 'function') { const accessResult = field.access[operation]({ id, @@ -82,10 +81,14 @@ export const populateFieldPermissions = async ({ } }) - // If this field has nested fields, we'll await this promise before recursing - // Otherwise, add it to the promises array for parallel processing - if ('fields' in field && field.fields) { - pendingAccessPromise = promise + // If this field has nested content (fields or blocks), collect promises to await before processing nested content + // Otherwise, add to global promises array for parallel processing + if ( + ('fields' in field && field.fields) || + ('blocks' in field && field.blocks?.length) || + ('blockReferences' in field && field.blockReferences?.length) + ) { + fieldAccessPromises.push(promise) } else { promises.push(promise) } @@ -99,39 +102,55 @@ export const populateFieldPermissions = async ({ permission: parentPermissionForOperation, } } + } + } - if ('fields' in field && field.fields) { - if (!fieldPermissions.fields) { - fieldPermissions.fields = {} - } + // Await all field-level access promises before processing nested content + if (fieldAccessPromises.length > 0) { + await Promise.all(fieldAccessPromises) + } - // For correct calculation of `parentPermissionForOperation`, the parentPermissionsObject must be completely - // calculated and awaited before calculating field permissions of nested fields. - if (pendingAccessPromise) { - await pendingAccessPromise - } + // Handle named fields with nested content + if ('name' in field && field.name) { + const fieldPermissions: FieldPermissions = permissionsObject[field.name]! - await populateFieldPermissions({ - id, - blockReferencesPermissions, - data, - fields: field.fields, - operations, - parentPermissionsObject: fieldPermissions, - permissionsObject: fieldPermissions.fields, - promises, - req, - }) + if ('fields' in field && field.fields) { + if (!fieldPermissions.fields) { + fieldPermissions.fields = {} } - if ( - ('blocks' in field && field.blocks?.length) || - ('blockReferences' in field && field.blockReferences?.length) - ) { - if (!fieldPermissions.blocks) { - fieldPermissions.blocks = {} + await populateFieldPermissions({ + id, + blockReferencesPermissions, + data, + fields: field.fields, + operations, + parentPermissionsObject: fieldPermissions, + permissionsObject: fieldPermissions.fields, + promises, + req, + }) + } + + if ( + ('blocks' in field && field.blocks?.length) || + ('blockReferences' in field && field.blockReferences?.length) + ) { + if (!fieldPermissions.blocks) { + fieldPermissions.blocks = {} + } + const blocksPermissions: BlocksPermissions = fieldPermissions.blocks + + // First, set up permissions for all operations for all blocks + for (const operation of operations) { + // Fields don't have all operations of a collection + if (operation === 'delete' || operation === 'readVersions' || operation === 'unlock') { + continue } - const blocksPermissions: BlocksPermissions = fieldPermissions.blocks + + const parentPermissionForOperation = ( + parentPermissionsObject[operation as keyof typeof parentPermissionsObject] as Permission + )?.permission for (const _block of field.blockReferences ?? field.blocks) { const block = typeof _block === 'string' ? req.payload.blocks[_block] : _block @@ -141,126 +160,132 @@ export const populateFieldPermissions = async ({ continue } - if (!blocksPermissions[block.slug]) { - blocksPermissions[block.slug] = {} as BlockPermissions - } - + // Handle block references - check if we've seen this block before if (typeof _block === 'string') { const blockReferencePermissions = blockReferencesPermissions[_block] if (blockReferencePermissions) { if (isThenable(blockReferencePermissions)) { - // Earlier access to this block is still pending, so await it instead of re-running populateFieldPermissions + // Earlier access to this block is still pending, so await it blocksPermissions[block.slug] = await blockReferencePermissions } else { // It's already a resolved policy object blocksPermissions[block.slug] = blockReferencePermissions } continue - } else { - // We have not seen this block slug yet. Immediately create a promise - // so that any parallel calls will just await this same promise - // instead of re-running populateFieldPermissions. - const blockPromise = (async (): Promise => { - // If the block doesn't exist yet in our permissionsObject, initialize it - // Use field-level permission instead of parentPermissionForOperation for blocks - // This ensures that if the field has access control, it applies to all blocks in the field - const fieldPermission = - fieldPermissions[operation]?.permission ?? parentPermissionForOperation - - if (!blocksPermissions[block.slug]) { - blocksPermissions[block.slug] = { - fields: {}, - [operation]: { permission: fieldPermission }, - } as BlockPermissions - } else if (!blocksPermissions[block.slug]?.[operation]) { - blocksPermissions[block.slug]![operation] = { - permission: fieldPermission, - } - } - - const blockPermission = blocksPermissions[block.slug]! - if (!blockPermission.fields) { - blockPermission.fields = {} - } - - await populateFieldPermissions({ - id, - blockReferencesPermissions, - data, - fields: block.fields, - operations, - parentPermissionsObject: blockPermission, - permissionsObject: blockPermission.fields, - promises, - req, - }) - - return blockPermission - })() - - blockReferencesPermissions[_block] = blockPromise - blocksPermissions[block.slug] = await blockPromise - continue } } - // Use field-level permission instead of parentPermissionForOperation for blocks - const fieldPermission = - fieldPermissions[operation]?.permission ?? parentPermissionForOperation - + // Initialize block permissions object if needed if (!blocksPermissions[block.slug]) { - blocksPermissions[block.slug] = { - fields: {}, - [operation]: { permission: fieldPermission }, - } as BlockPermissions + blocksPermissions[block.slug] = {} as BlockPermissions } - const blockPermission = blocksPermissions[block.slug] - if (!blockPermission) { - // Should never happen since we just set it above, but TypeScript needs the check - continue - } + const blockPermission = blocksPermissions[block.slug]! + // Set permission for this operation if (!blockPermission[operation]) { + const fieldPermission = + fieldPermissions[operation]?.permission ?? parentPermissionForOperation blockPermission[operation] = { permission: fieldPermission, } } + } + } - if (!blockPermission.fields) { - blockPermission.fields = {} - } + // Now process nested content for each unique block (once per block, not once per operation) + const processedBlocks = new Set() + for (const _block of field.blockReferences ?? field.blocks) { + const block = typeof _block === 'string' ? req.payload.blocks[_block] : _block - // For correct calculation of `parentPermissionForOperation`, the parentPermissionsObject must be completely - // calculated and awaited before calculating field permissions of nested fields. - await populateFieldPermissions({ - id, - blockReferencesPermissions, - data, - fields: block.fields, - operations, - parentPermissionsObject: blockPermission, - permissionsObject: blockPermission.fields, - promises, - req, - }) + // Skip if block doesn't exist (invalid block reference) + if (!block || processedBlocks.has(block.slug)) { + continue + } + processedBlocks.add(block.slug) + + // Handle block references with caching + if (typeof _block === 'string' && !blockReferencesPermissions[_block]) { + blockReferencesPermissions[_block] = (async (): Promise => { + const blockPermission = blocksPermissions[block.slug]! + if (!blockPermission.fields) { + blockPermission.fields = {} + } + + await populateFieldPermissions({ + id, + blockReferencesPermissions, + data, + fields: block.fields, + operations, + parentPermissionsObject: blockPermission, + permissionsObject: blockPermission.fields, + promises, + req, + }) + + return blockPermission + })() + + blocksPermissions[block.slug] = await blockReferencesPermissions[_block] + continue + } + + // Process inline blocks or already-resolved references + const blockPermission = blocksPermissions[block.slug] + if (!blockPermission) { + continue + } + + if (!blockPermission.fields) { + blockPermission.fields = {} } + + await populateFieldPermissions({ + id, + blockReferencesPermissions, + data, + fields: block.fields, + operations, + parentPermissionsObject: blockPermission, + permissionsObject: blockPermission.fields, + promises, + req, + }) } - } else if ('fields' in field && field.fields) { - // Field does not have a name => same parentPermissionsObject => no need to await current level - await populateFieldPermissions({ - id, - blockReferencesPermissions, - data, - fields: field.fields, - operations, - // Field does not have a name here => use parent permissions object - parentPermissionsObject, - permissionsObject, - promises, - req, - }) - } else if (field.type === 'tabs') { + } + } + + // Handle unnamed group fields + if ('fields' in field && field.fields && !('name' in field && field.name)) { + // Field does not have a name => same parentPermissionsObject => no need to await current level + await populateFieldPermissions({ + id, + blockReferencesPermissions, + data, + fields: field.fields, + operations, + // Field does not have a name here => use parent permissions object + parentPermissionsObject, + permissionsObject, + promises, + req, + }) + } + + // Handle tabs fields + if (field.type === 'tabs') { + // Process tabs for all operations + for (const operation of operations) { + // Fields don't have all operations of a collection + if (operation === 'delete' || operation === 'readVersions' || operation === 'unlock') { + continue + } + + const parentPermissionForOperation = ( + parentPermissionsObject[operation as keyof typeof parentPermissionsObject] as Permission + )?.permission + for (const tab of field.tabs) { if (tabHasName(tab)) { if (!permissionsObject[tab.name]) { @@ -271,41 +296,43 @@ export const populateFieldPermissions = async ({ } else if (!permissionsObject[tab.name]![operation]) { permissionsObject[tab.name]![operation] = { permission: parentPermissionForOperation } } + } + } + } - const tabPermissions: FieldPermissions = permissionsObject[tab.name]! + for (const tab of field.tabs) { + if (tabHasName(tab)) { + const tabPermissions: FieldPermissions = permissionsObject[tab.name]! - if (!tabPermissions.fields) { - tabPermissions.fields = {} - } - - // For tabs with names, we don't have async access functions on the tab itself, - // so no need to await before recursing. The permission is set synchronously above. - await populateFieldPermissions({ - id, - blockReferencesPermissions, - data, - fields: tab.fields, - operations, - parentPermissionsObject: tabPermissions, - permissionsObject: tabPermissions.fields, - promises, - req, - }) - } else { - // Tab does not have a name => same parentPermissionsObject => no need to await current level - await populateFieldPermissions({ - id, - blockReferencesPermissions, - data, - fields: tab.fields, - operations, - // Tab does not have a name here => use parent permissions object - parentPermissionsObject, - permissionsObject, - promises, - req, - }) + if (!tabPermissions.fields) { + tabPermissions.fields = {} } + + await populateFieldPermissions({ + id, + blockReferencesPermissions, + data, + fields: tab.fields, + operations, + parentPermissionsObject: tabPermissions, + permissionsObject: tabPermissions.fields, + promises, + req, + }) + } else { + // Tab does not have a name => same parentPermissionsObject => no need to await current level + await populateFieldPermissions({ + id, + blockReferencesPermissions, + data, + fields: tab.fields, + operations, + // Tab does not have a name here => use parent permissions object + parentPermissionsObject, + permissionsObject, + promises, + req, + }) } } } From 26ea932286ceb0407ac755a200585947478dc7fa Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sun, 16 Nov 2025 20:01:53 -0800 Subject: [PATCH 08/42] fix the issue --- .../views/Document/getDocumentPermissions.tsx | 46 +++++++++---------- .../src/collections/operations/docAccess.ts | 8 +++- .../src/globals/operations/docAccess.ts | 9 +++- packages/payload/src/types/index.ts | 2 + .../getEntityPermissions.ts | 17 ++++++- test/access-control/config.ts | 7 +-- 6 files changed, 53 insertions(+), 36 deletions(-) diff --git a/packages/next/src/views/Document/getDocumentPermissions.tsx b/packages/next/src/views/Document/getDocumentPermissions.tsx index 3e37739a9ad..ee9d606150b 100644 --- a/packages/next/src/views/Document/getDocumentPermissions.tsx +++ b/packages/next/src/views/Document/getDocumentPermissions.tsx @@ -35,29 +35,27 @@ export const getDocumentPermissions = async (args: { collection: { config: collectionConfig, }, - req: { - ...req, - data: { - ...data, - _status: 'draft', - }, + data: { + ...data, + _status: 'draft', }, + req, }) if (collectionConfig.versions?.drafts) { - hasPublishPermission = await docAccessOperation({ - id, - collection: { - config: collectionConfig, - }, - req: { - ...req, + hasPublishPermission = ( + await docAccessOperation({ + id, + collection: { + config: collectionConfig, + }, data: { ...data, _status: 'published', }, - }, - }).then((permissions) => permissions.update) + req, + }) + ).update } } catch (err) { logError({ err, payload: req.payload }) @@ -67,24 +65,22 @@ export const getDocumentPermissions = async (args: { if (globalConfig) { try { docPermissions = await docAccessOperationGlobal({ + data, globalConfig, - req: { - ...req, - data, - }, + req, }) if (globalConfig.versions?.drafts) { - hasPublishPermission = await docAccessOperationGlobal({ - globalConfig, - req: { - ...req, + hasPublishPermission = ( + await docAccessOperationGlobal({ data: { ...data, _status: 'published', }, - }, - }).then((permissions) => permissions.update) + globalConfig, + req, + }) + ).update } } catch (err) { logError({ err, payload: req.payload }) diff --git a/packages/payload/src/collections/operations/docAccess.ts b/packages/payload/src/collections/operations/docAccess.ts index ef8deea7b63..9d793398274 100644 --- a/packages/payload/src/collections/operations/docAccess.ts +++ b/packages/payload/src/collections/operations/docAccess.ts @@ -1,5 +1,5 @@ import type { SanitizedCollectionPermission } from '../../auth/index.js' -import type { AllOperations, PayloadRequest } from '../../types/index.js' +import type { AllOperations, JsonObject, PayloadRequest } from '../../types/index.js' import type { Collection } from '../config/types.js' import { getEntityPermissions } from '../../utilities/getEntityPermissions/getEntityPermissions.js' @@ -10,6 +10,10 @@ const allOperations: AllOperations[] = ['create', 'read', 'update', 'delete'] type Arguments = { collection: Collection + /** + * If the document data is passed, it will be used to check access instead of fetching the document from the database. + */ + data?: JsonObject id: number | string req: PayloadRequest } @@ -18,6 +22,7 @@ export async function docAccessOperation(args: Arguments): Promise => { - const { globalConfig, req } = args + const { data, globalConfig, req } = args const globalOperations: AllOperations[] = ['read', 'update'] @@ -27,6 +31,7 @@ export const docAccessOperation = async (args: Arguments): Promise = TEntityType exten type Args = { blockReferencesPermissions: BlockReferencesPermissions + /** + * If the document data is passed, it will be used to check access instead of fetching the document from the database. + */ + data?: JsonObject entity: TEntityType extends 'collection' ? SanitizedCollectionConfig : SanitizedGlobalConfig entityType: TEntityType /** @@ -69,8 +73,17 @@ const topLevelGlobalPermissions = ['read', 'readVersions', 'update'] export async function getEntityPermissions( args: Args, ): Promise> { - const { id, blockReferencesPermissions, entity, entityType, fetchData, operations, req } = args - const { data: _data, locale: _locale, user } = req + const { + id, + blockReferencesPermissions, + data: _data, + entity, + entityType, + fetchData, + operations, + req, + } = args + const { locale: _locale, user } = req const locale = _locale ? _locale : undefined diff --git a/test/access-control/config.ts b/test/access-control/config.ts index fdc1545353d..3db42e81e1c 100644 --- a/test/access-control/config.ts +++ b/test/access-control/config.ts @@ -631,12 +631,7 @@ export default buildConfigWithDefaults( name: 'restricted', type: 'text', access: { - update: ({ data, doc }) => { - console.log(`update access control.`, { - data, - doc, - returned: !data?.field1, - }) + update: ({ data }) => { return !data?.isRestricted }, }, From 521b02721656e35d3cd3fb5ef83a8704f76e951c Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sun, 16 Nov 2025 20:09:02 -0800 Subject: [PATCH 09/42] delete old function --- .../src/utilities/getEntityPolicies.ts | 392 ------------------ 1 file changed, 392 deletions(-) delete mode 100644 packages/payload/src/utilities/getEntityPolicies.ts diff --git a/packages/payload/src/utilities/getEntityPolicies.ts b/packages/payload/src/utilities/getEntityPolicies.ts deleted file mode 100644 index 3b2b95fd9ec..00000000000 --- a/packages/payload/src/utilities/getEntityPolicies.ts +++ /dev/null @@ -1,392 +0,0 @@ -import type { CollectionPermission, FieldsPermissions, GlobalPermission } from '../auth/types.js' -import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js' -import type { Access } from '../config/types.js' -import type { Field, FieldAccess } from '../fields/config/types.js' -import type { SanitizedGlobalConfig } from '../globals/config/types.js' -import type { BlockSlug } from '../index.js' -import type { AllOperations, JsonObject, Payload, PayloadRequest, Where } from '../types/index.js' - -import { combineQueries } from '../database/combineQueries.js' -import { tabHasName } from '../fields/config/types.js' - -export type BlockPolicies = Record> -type Args = { - blockPolicies: BlockPolicies - entity: SanitizedCollectionConfig | SanitizedGlobalConfig - id?: number | string - operations: AllOperations[] - req: PayloadRequest - type: 'collection' | 'global' -} - -type ReturnType = T['type'] extends 'global' - ? GlobalPermission - : CollectionPermission - -type CreateAccessPromise = (args: { - access: Access | FieldAccess - accessLevel: 'entity' | 'field' - disableWhere?: boolean - operation: AllOperations - policiesObj: CollectionPermission | GlobalPermission -}) => Promise - -type EntityDoc = JsonObject | TypeWithID - -/** - * Build up permissions object for an entity (collection or global). SCHEMA Permissions - disregards siblingData - */ -export async function getEntityPolicies(args: T): Promise> { - const { id, type, blockPolicies, entity, operations, req } = args - const { data, locale, payload, user } = req - const isLoggedIn = !!user - - const policies = { - fields: {}, - } as ReturnType - - let docBeingAccessed: EntityDoc | Promise | undefined - - async function getEntityDoc({ - operation, - where, - }: { operation?: AllOperations; where?: Where } = {}): Promise { - if (!entity.slug) { - return undefined - } - - if (type === 'global') { - return payload.findGlobal({ - slug: entity.slug, - depth: 0, - fallbackLocale: null, - locale, - overrideAccess: true, - req, - }) - } - - if (type === 'collection' && id) { - if (typeof where === 'object') { - const options = { - collection: entity.slug, - depth: 0, - fallbackLocale: null, - limit: 1, - locale, - overrideAccess: true, - req, - } - - if (operation === 'readVersions') { - const paginatedRes = await payload.findVersions({ - ...options, - where: combineQueries(where, { parent: { equals: id } }), - }) - return paginatedRes?.docs?.[0] || undefined - } - - const paginatedRes = await payload.find({ - ...options, - pagination: false, - where: combineQueries(where, { id: { equals: id } }), - }) - - return paginatedRes?.docs?.[0] || undefined - } - - return payload.findByID({ - id, - collection: entity.slug, - depth: 0, - fallbackLocale: null, - locale, - overrideAccess: true, - req, - trash: true, - }) - } - } - - const createAccessPromise: CreateAccessPromise = async ({ - access, - accessLevel, - disableWhere = false, - operation, - policiesObj, - }) => { - const mutablePolicies = policiesObj as Record - if (accessLevel === 'field' && docBeingAccessed === undefined) { - // assign docBeingAccessed first as the promise to avoid multiple calls to getEntityDoc - docBeingAccessed = getEntityDoc().then((doc) => { - docBeingAccessed = doc - }) - } - - // awaiting the promise to ensure docBeingAccessed is assigned before it is used - await docBeingAccessed - - // https://payloadcms.slack.com/archives/C048Z9C2BEX/p1702054928343769 - const accessResult = await access({ id, data, doc: docBeingAccessed, req }) - - // Where query was returned from access function => check if document is returned when querying with where - if (typeof accessResult === 'object' && !disableWhere) { - mutablePolicies[operation] = { - permission: - id || type === 'global' - ? !!(await getEntityDoc({ operation, where: accessResult })) - : true, - where: accessResult, - } - } else if (mutablePolicies[operation]?.permission !== false) { - mutablePolicies[operation] = { - permission: !!accessResult, - } - } - } - - for (const operation of operations) { - if (typeof entity.access[operation as keyof typeof entity.access] === 'function') { - await createAccessPromise({ - access: entity.access[operation as keyof typeof entity.access], - accessLevel: 'entity', - operation, - policiesObj: policies, - }) - } else { - ;(policies as any)[operation] = { - permission: isLoggedIn, - } - } - - await executeFieldPolicies({ - blockPolicies, - createAccessPromise, - entityPermission: (policies as any)[operation].permission as boolean, - fields: entity.fields, - operation, - payload, - policiesObj: policies, - }) - } - - return policies -} - -/** - * Build up permissions object and run access functions for each field of an entity - */ -const executeFieldPolicies = async ({ - blockPolicies, - createAccessPromise, - entityPermission, - fields, - operation, - payload, - policiesObj, -}: { - blockPolicies: BlockPolicies - createAccessPromise: CreateAccessPromise - entityPermission: boolean - fields: Field[] - operation: AllOperations - payload: Payload - policiesObj: CollectionPermission | FieldsPermissions | GlobalPermission -}) => { - const mutablePolicies = policiesObj.fields as Record - - // Fields don't have all operations of a collection - if (operation === 'delete' || operation === 'readVersions' || operation === 'unlock') { - return - } - - await Promise.all( - fields.map(async (field) => { - if ('name' in field && field.name) { - if (!mutablePolicies[field.name]) { - mutablePolicies[field.name] = {} - } - - if ('access' in field && field.access && typeof field.access[operation] === 'function') { - await createAccessPromise({ - access: field.access[operation], - accessLevel: 'field', - disableWhere: true, - operation, - policiesObj: mutablePolicies[field.name], - }) - } else { - mutablePolicies[field.name][operation] = { - permission: (policiesObj as any)[operation]?.permission, - } - } - - if ('fields' in field && field.fields) { - if (!mutablePolicies[field.name].fields) { - mutablePolicies[field.name].fields = {} - } - - await executeFieldPolicies({ - blockPolicies, - createAccessPromise, - entityPermission, - fields: field.fields, - operation, - payload, - policiesObj: mutablePolicies[field.name], - }) - } - - if ( - ('blocks' in field && field.blocks?.length) || - ('blockReferences' in field && field.blockReferences?.length) - ) { - if (!mutablePolicies[field.name]?.blocks) { - mutablePolicies[field.name].blocks = {} - } - - await Promise.all( - (field.blockReferences ?? field.blocks).map(async (_block) => { - const block = typeof _block === 'string' ? payload.blocks[_block] : _block - - // Skip if block doesn't exist (invalid block reference) - if (!block) { - return - } - - if (typeof _block === 'string') { - if (blockPolicies[_block]) { - if (typeof blockPolicies[_block].then === 'function') { - // Earlier access to this block is still pending, so await it instead of re-running executeFieldPolicies - mutablePolicies[field.name].blocks[block.slug] = await blockPolicies[_block] - } else { - // It's already a resolved policy object - mutablePolicies[field.name].blocks[block.slug] = blockPolicies[_block] - } - return - } else { - // We have not seen this block slug yet. Immediately create a promise - // so that any parallel calls will just await this same promise - // instead of re-running executeFieldPolicies. - blockPolicies[_block] = (async () => { - // If the block doesn't exist yet in our mutablePolicies, initialize it - if (!mutablePolicies[field.name].blocks?.[block.slug]) { - // Use field-level permission instead of entityPermission for blocks - // This ensures that if the field has access control, it applies to all blocks in the field - const fieldPermission = - mutablePolicies[field.name][operation]?.permission ?? entityPermission - - mutablePolicies[field.name].blocks[block.slug] = { - fields: {}, - [operation]: { permission: fieldPermission }, - } - } else if (!mutablePolicies[field.name].blocks[block.slug][operation]) { - // Use field-level permission for consistency - const fieldPermission = - mutablePolicies[field.name][operation]?.permission ?? entityPermission - - mutablePolicies[field.name].blocks[block.slug][operation] = { - permission: fieldPermission, - } - } - - await executeFieldPolicies({ - blockPolicies, - createAccessPromise, - entityPermission: - mutablePolicies[field.name][operation]?.permission ?? entityPermission, - fields: block.fields, - operation, - payload, - policiesObj: mutablePolicies[field.name].blocks[block.slug], - }) - - return mutablePolicies[field.name].blocks[block.slug] - })() - - mutablePolicies[field.name].blocks[block.slug] = await blockPolicies[_block] - blockPolicies[_block] = mutablePolicies[field.name].blocks[block.slug] - return - } - } - - if (!mutablePolicies[field.name].blocks?.[block.slug]) { - // Use field-level permission instead of entityPermission for blocks - const fieldPermission = - mutablePolicies[field.name][operation]?.permission ?? entityPermission - - mutablePolicies[field.name].blocks[block.slug] = { - fields: {}, - [operation]: { permission: fieldPermission }, - } - } else if (!mutablePolicies[field.name].blocks[block.slug][operation]) { - // Use field-level permission for consistency - const fieldPermission = - mutablePolicies[field.name][operation]?.permission ?? entityPermission - - mutablePolicies[field.name].blocks[block.slug][operation] = { - permission: fieldPermission, - } - } - - await executeFieldPolicies({ - blockPolicies, - createAccessPromise, - entityPermission: - mutablePolicies[field.name][operation]?.permission ?? entityPermission, - fields: block.fields, - operation, - payload, - policiesObj: mutablePolicies[field.name].blocks[block.slug], - }) - }), - ) - } - } else if ('fields' in field && field.fields) { - await executeFieldPolicies({ - blockPolicies, - createAccessPromise, - entityPermission, - fields: field.fields, - operation, - payload, - policiesObj, - }) - } else if (field.type === 'tabs') { - await Promise.all( - field.tabs.map(async (tab) => { - if (tabHasName(tab)) { - if (!mutablePolicies[tab.name]) { - mutablePolicies[tab.name] = { - fields: {}, - [operation]: { permission: entityPermission }, - } - } else if (!mutablePolicies[tab.name][operation]) { - mutablePolicies[tab.name][operation] = { permission: entityPermission } - } - await executeFieldPolicies({ - blockPolicies, - createAccessPromise, - entityPermission, - fields: tab.fields, - operation, - payload, - policiesObj: mutablePolicies[tab.name], - }) - } else { - await executeFieldPolicies({ - blockPolicies, - createAccessPromise, - entityPermission, - fields: tab.fields, - operation, - payload, - policiesObj, - }) - } - }), - ) - } - }), - ) -} From b5fd53304b6bc8b9d7dea655abb53f6d4cb23b4f Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sun, 16 Nov 2025 21:11:23 -0800 Subject: [PATCH 10/42] Revert "delete old function" This reverts commit 521b02721656e35d3cd3fb5ef83a8704f76e951c. --- .../src/utilities/getEntityPolicies.ts | 392 ++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 packages/payload/src/utilities/getEntityPolicies.ts diff --git a/packages/payload/src/utilities/getEntityPolicies.ts b/packages/payload/src/utilities/getEntityPolicies.ts new file mode 100644 index 00000000000..3b2b95fd9ec --- /dev/null +++ b/packages/payload/src/utilities/getEntityPolicies.ts @@ -0,0 +1,392 @@ +import type { CollectionPermission, FieldsPermissions, GlobalPermission } from '../auth/types.js' +import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js' +import type { Access } from '../config/types.js' +import type { Field, FieldAccess } from '../fields/config/types.js' +import type { SanitizedGlobalConfig } from '../globals/config/types.js' +import type { BlockSlug } from '../index.js' +import type { AllOperations, JsonObject, Payload, PayloadRequest, Where } from '../types/index.js' + +import { combineQueries } from '../database/combineQueries.js' +import { tabHasName } from '../fields/config/types.js' + +export type BlockPolicies = Record> +type Args = { + blockPolicies: BlockPolicies + entity: SanitizedCollectionConfig | SanitizedGlobalConfig + id?: number | string + operations: AllOperations[] + req: PayloadRequest + type: 'collection' | 'global' +} + +type ReturnType = T['type'] extends 'global' + ? GlobalPermission + : CollectionPermission + +type CreateAccessPromise = (args: { + access: Access | FieldAccess + accessLevel: 'entity' | 'field' + disableWhere?: boolean + operation: AllOperations + policiesObj: CollectionPermission | GlobalPermission +}) => Promise + +type EntityDoc = JsonObject | TypeWithID + +/** + * Build up permissions object for an entity (collection or global). SCHEMA Permissions - disregards siblingData + */ +export async function getEntityPolicies(args: T): Promise> { + const { id, type, blockPolicies, entity, operations, req } = args + const { data, locale, payload, user } = req + const isLoggedIn = !!user + + const policies = { + fields: {}, + } as ReturnType + + let docBeingAccessed: EntityDoc | Promise | undefined + + async function getEntityDoc({ + operation, + where, + }: { operation?: AllOperations; where?: Where } = {}): Promise { + if (!entity.slug) { + return undefined + } + + if (type === 'global') { + return payload.findGlobal({ + slug: entity.slug, + depth: 0, + fallbackLocale: null, + locale, + overrideAccess: true, + req, + }) + } + + if (type === 'collection' && id) { + if (typeof where === 'object') { + const options = { + collection: entity.slug, + depth: 0, + fallbackLocale: null, + limit: 1, + locale, + overrideAccess: true, + req, + } + + if (operation === 'readVersions') { + const paginatedRes = await payload.findVersions({ + ...options, + where: combineQueries(where, { parent: { equals: id } }), + }) + return paginatedRes?.docs?.[0] || undefined + } + + const paginatedRes = await payload.find({ + ...options, + pagination: false, + where: combineQueries(where, { id: { equals: id } }), + }) + + return paginatedRes?.docs?.[0] || undefined + } + + return payload.findByID({ + id, + collection: entity.slug, + depth: 0, + fallbackLocale: null, + locale, + overrideAccess: true, + req, + trash: true, + }) + } + } + + const createAccessPromise: CreateAccessPromise = async ({ + access, + accessLevel, + disableWhere = false, + operation, + policiesObj, + }) => { + const mutablePolicies = policiesObj as Record + if (accessLevel === 'field' && docBeingAccessed === undefined) { + // assign docBeingAccessed first as the promise to avoid multiple calls to getEntityDoc + docBeingAccessed = getEntityDoc().then((doc) => { + docBeingAccessed = doc + }) + } + + // awaiting the promise to ensure docBeingAccessed is assigned before it is used + await docBeingAccessed + + // https://payloadcms.slack.com/archives/C048Z9C2BEX/p1702054928343769 + const accessResult = await access({ id, data, doc: docBeingAccessed, req }) + + // Where query was returned from access function => check if document is returned when querying with where + if (typeof accessResult === 'object' && !disableWhere) { + mutablePolicies[operation] = { + permission: + id || type === 'global' + ? !!(await getEntityDoc({ operation, where: accessResult })) + : true, + where: accessResult, + } + } else if (mutablePolicies[operation]?.permission !== false) { + mutablePolicies[operation] = { + permission: !!accessResult, + } + } + } + + for (const operation of operations) { + if (typeof entity.access[operation as keyof typeof entity.access] === 'function') { + await createAccessPromise({ + access: entity.access[operation as keyof typeof entity.access], + accessLevel: 'entity', + operation, + policiesObj: policies, + }) + } else { + ;(policies as any)[operation] = { + permission: isLoggedIn, + } + } + + await executeFieldPolicies({ + blockPolicies, + createAccessPromise, + entityPermission: (policies as any)[operation].permission as boolean, + fields: entity.fields, + operation, + payload, + policiesObj: policies, + }) + } + + return policies +} + +/** + * Build up permissions object and run access functions for each field of an entity + */ +const executeFieldPolicies = async ({ + blockPolicies, + createAccessPromise, + entityPermission, + fields, + operation, + payload, + policiesObj, +}: { + blockPolicies: BlockPolicies + createAccessPromise: CreateAccessPromise + entityPermission: boolean + fields: Field[] + operation: AllOperations + payload: Payload + policiesObj: CollectionPermission | FieldsPermissions | GlobalPermission +}) => { + const mutablePolicies = policiesObj.fields as Record + + // Fields don't have all operations of a collection + if (operation === 'delete' || operation === 'readVersions' || operation === 'unlock') { + return + } + + await Promise.all( + fields.map(async (field) => { + if ('name' in field && field.name) { + if (!mutablePolicies[field.name]) { + mutablePolicies[field.name] = {} + } + + if ('access' in field && field.access && typeof field.access[operation] === 'function') { + await createAccessPromise({ + access: field.access[operation], + accessLevel: 'field', + disableWhere: true, + operation, + policiesObj: mutablePolicies[field.name], + }) + } else { + mutablePolicies[field.name][operation] = { + permission: (policiesObj as any)[operation]?.permission, + } + } + + if ('fields' in field && field.fields) { + if (!mutablePolicies[field.name].fields) { + mutablePolicies[field.name].fields = {} + } + + await executeFieldPolicies({ + blockPolicies, + createAccessPromise, + entityPermission, + fields: field.fields, + operation, + payload, + policiesObj: mutablePolicies[field.name], + }) + } + + if ( + ('blocks' in field && field.blocks?.length) || + ('blockReferences' in field && field.blockReferences?.length) + ) { + if (!mutablePolicies[field.name]?.blocks) { + mutablePolicies[field.name].blocks = {} + } + + await Promise.all( + (field.blockReferences ?? field.blocks).map(async (_block) => { + const block = typeof _block === 'string' ? payload.blocks[_block] : _block + + // Skip if block doesn't exist (invalid block reference) + if (!block) { + return + } + + if (typeof _block === 'string') { + if (blockPolicies[_block]) { + if (typeof blockPolicies[_block].then === 'function') { + // Earlier access to this block is still pending, so await it instead of re-running executeFieldPolicies + mutablePolicies[field.name].blocks[block.slug] = await blockPolicies[_block] + } else { + // It's already a resolved policy object + mutablePolicies[field.name].blocks[block.slug] = blockPolicies[_block] + } + return + } else { + // We have not seen this block slug yet. Immediately create a promise + // so that any parallel calls will just await this same promise + // instead of re-running executeFieldPolicies. + blockPolicies[_block] = (async () => { + // If the block doesn't exist yet in our mutablePolicies, initialize it + if (!mutablePolicies[field.name].blocks?.[block.slug]) { + // Use field-level permission instead of entityPermission for blocks + // This ensures that if the field has access control, it applies to all blocks in the field + const fieldPermission = + mutablePolicies[field.name][operation]?.permission ?? entityPermission + + mutablePolicies[field.name].blocks[block.slug] = { + fields: {}, + [operation]: { permission: fieldPermission }, + } + } else if (!mutablePolicies[field.name].blocks[block.slug][operation]) { + // Use field-level permission for consistency + const fieldPermission = + mutablePolicies[field.name][operation]?.permission ?? entityPermission + + mutablePolicies[field.name].blocks[block.slug][operation] = { + permission: fieldPermission, + } + } + + await executeFieldPolicies({ + blockPolicies, + createAccessPromise, + entityPermission: + mutablePolicies[field.name][operation]?.permission ?? entityPermission, + fields: block.fields, + operation, + payload, + policiesObj: mutablePolicies[field.name].blocks[block.slug], + }) + + return mutablePolicies[field.name].blocks[block.slug] + })() + + mutablePolicies[field.name].blocks[block.slug] = await blockPolicies[_block] + blockPolicies[_block] = mutablePolicies[field.name].blocks[block.slug] + return + } + } + + if (!mutablePolicies[field.name].blocks?.[block.slug]) { + // Use field-level permission instead of entityPermission for blocks + const fieldPermission = + mutablePolicies[field.name][operation]?.permission ?? entityPermission + + mutablePolicies[field.name].blocks[block.slug] = { + fields: {}, + [operation]: { permission: fieldPermission }, + } + } else if (!mutablePolicies[field.name].blocks[block.slug][operation]) { + // Use field-level permission for consistency + const fieldPermission = + mutablePolicies[field.name][operation]?.permission ?? entityPermission + + mutablePolicies[field.name].blocks[block.slug][operation] = { + permission: fieldPermission, + } + } + + await executeFieldPolicies({ + blockPolicies, + createAccessPromise, + entityPermission: + mutablePolicies[field.name][operation]?.permission ?? entityPermission, + fields: block.fields, + operation, + payload, + policiesObj: mutablePolicies[field.name].blocks[block.slug], + }) + }), + ) + } + } else if ('fields' in field && field.fields) { + await executeFieldPolicies({ + blockPolicies, + createAccessPromise, + entityPermission, + fields: field.fields, + operation, + payload, + policiesObj, + }) + } else if (field.type === 'tabs') { + await Promise.all( + field.tabs.map(async (tab) => { + if (tabHasName(tab)) { + if (!mutablePolicies[tab.name]) { + mutablePolicies[tab.name] = { + fields: {}, + [operation]: { permission: entityPermission }, + } + } else if (!mutablePolicies[tab.name][operation]) { + mutablePolicies[tab.name][operation] = { permission: entityPermission } + } + await executeFieldPolicies({ + blockPolicies, + createAccessPromise, + entityPermission, + fields: tab.fields, + operation, + payload, + policiesObj: mutablePolicies[tab.name], + }) + } else { + await executeFieldPolicies({ + blockPolicies, + createAccessPromise, + entityPermission, + fields: tab.fields, + operation, + payload, + policiesObj, + }) + } + }), + ) + } + }), + ) +} From 3f4ae604aec6848c6d7383d7f877a6d92cd6b932 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sun, 16 Nov 2025 21:22:16 -0800 Subject: [PATCH 11/42] fix issue --- packages/payload/src/database/queryValidation/types.ts | 1 + .../src/database/queryValidation/validateQueryPaths.ts | 1 + .../src/database/queryValidation/validateSearchParams.ts | 1 + .../utilities/getEntityPermissions/getEntityPermissions.ts | 5 ++++- 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/payload/src/database/queryValidation/types.ts b/packages/payload/src/database/queryValidation/types.ts index 6d5c4dac946..d2f2bd79db0 100644 --- a/packages/payload/src/database/queryValidation/types.ts +++ b/packages/payload/src/database/queryValidation/types.ts @@ -1,6 +1,7 @@ import type { CollectionPermission, GlobalPermission } from '../../auth/index.js' import type { FlattenedField } from '../../fields/config/types.js' +// TODO: Rename to EntityPermissions in 4.0 export type EntityPolicies = { collections?: { [collectionSlug: string]: CollectionPermission diff --git a/packages/payload/src/database/queryValidation/validateQueryPaths.ts b/packages/payload/src/database/queryValidation/validateQueryPaths.ts index 8986eb14cea..47a50c94830 100644 --- a/packages/payload/src/database/queryValidation/validateQueryPaths.ts +++ b/packages/payload/src/database/queryValidation/validateQueryPaths.ts @@ -11,6 +11,7 @@ import { validateSearchParam } from './validateSearchParams.js' type Args = { errors?: { path: string }[] overrideAccess: boolean + // TODO: Rename to permissions or entityPermissions in 4.0 policies?: EntityPolicies polymorphicJoin?: boolean req: PayloadRequest diff --git a/packages/payload/src/database/queryValidation/validateSearchParams.ts b/packages/payload/src/database/queryValidation/validateSearchParams.ts index 52cee781622..9d1075b1a6f 100644 --- a/packages/payload/src/database/queryValidation/validateSearchParams.ts +++ b/packages/payload/src/database/queryValidation/validateSearchParams.ts @@ -20,6 +20,7 @@ type Args = { overrideAccess: boolean parentIsLocalized?: boolean path: string + // TODO: Rename to permissions or entityPermissions in 4.0 policies: EntityPolicies polymorphicJoin?: boolean req: PayloadRequest diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts index 130691d1990..b06bd3fa6ca 100644 --- a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -249,7 +249,10 @@ const createEntityAccessPromise: CreateEntityAccessPromise = async ({ req, where: accessResult, }) - : false, + : // TODO: 4.0: Investigate defaulting to `false` here, if where query is returned but ignored as we don't + // have the document data available. This seems more secure. + // Alternatively, we could set permission to a third state, like 'unknown'. + true, where: accessResult, } } else if (permissionsObject[operation]?.permission !== false) { From d840ddedda1c8b9a23033a5b764af095069a2c5b Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sun, 16 Nov 2025 21:24:38 -0800 Subject: [PATCH 12/42] fix test --- test/access-control/e2e.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/access-control/e2e.spec.ts b/test/access-control/e2e.spec.ts index accaad9881c..cbe9f3e3860 100644 --- a/test/access-control/e2e.spec.ts +++ b/test/access-control/e2e.spec.ts @@ -167,6 +167,11 @@ describe('Access Control', () => { await payload.delete({ collection: 'field-restricted-update-based-on-data', + where: { + id: { + exists: true, + }, + }, }) const collectionURL = new AdminUrlUtil(serverURL, 'field-restricted-update-based-on-data') @@ -212,6 +217,11 @@ describe('Access Control', () => { await payload.delete({ collection: 'field-restricted-update-based-on-data', + where: { + id: { + exists: true, + }, + }, }) }) }) From 9214f74139047ed1dc5ebfda9a470262c7db2e60 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sun, 16 Nov 2025 21:51:33 -0800 Subject: [PATCH 13/42] fix unlock operation permission calculation --- packages/payload/src/auth/types.ts | 4 ++++ .../getEntityPermissions/getEntityPermissions.ts | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/payload/src/auth/types.ts b/packages/payload/src/auth/types.ts index 82af2714264..197bac6aad4 100644 --- a/packages/payload/src/auth/types.ts +++ b/packages/payload/src/auth/types.ts @@ -68,6 +68,8 @@ export type CollectionPermission = { fields: FieldsPermissions read: Permission readVersions?: Permission + // Auth-enabled Collections only + unlock?: Permission update: Permission } @@ -77,6 +79,8 @@ export type SanitizedCollectionPermission = { fields: SanitizedFieldsPermissions read?: true readVersions?: true + // Auth-enabled Collections only + unlock?: true update?: true } diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts index b06bd3fa6ca..e6ba020c16a 100644 --- a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -48,7 +48,14 @@ type Args = { } ) -const topLevelCollectionPermissions = ['create', 'delete', 'read', 'readVersions', 'update'] +const topLevelCollectionPermissions = [ + 'create', + 'delete', + 'read', + 'readVersions', + 'update', + 'unlock', +] const topLevelGlobalPermissions = ['read', 'readVersions', 'update'] /** From 30331f1b308749a3ae4f768650b6a81189013e4e Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sun, 16 Nov 2025 22:14:45 -0800 Subject: [PATCH 14/42] fix faulty test that broke only on postgres --- test/access-control/collections/Auth/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/access-control/collections/Auth/index.ts b/test/access-control/collections/Auth/index.ts index 33e76440f11..ba2258000db 100644 --- a/test/access-control/collections/Auth/index.ts +++ b/test/access-control/collections/Auth/index.ts @@ -18,7 +18,8 @@ export const Auth: CollectionConfig = { access: { update: ({ req: { user }, data }) => { const isUserOrSelf = - (user && 'roles' in user && user?.roles?.includes('admin')) || user?.id === data?.id + (user && 'roles' in user && user?.roles?.includes('admin')) || + (user?.id === data?.id && user?.collection === 'auth-collection') return isUserOrSelf }, }, From 9cf703cc7cdb02d20018b8872b509aeeecf0022e Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sun, 16 Nov 2025 22:37:18 -0800 Subject: [PATCH 15/42] fix issue --- .../next/src/views/Document/getDocumentPermissions.tsx | 3 +++ packages/payload/src/collections/operations/docAccess.ts | 9 ++++++--- .../getEntityPermissions/getEntityPermissions.ts | 8 ++++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/next/src/views/Document/getDocumentPermissions.tsx b/packages/next/src/views/Document/getDocumentPermissions.tsx index ee9d606150b..f71744c6537 100644 --- a/packages/next/src/views/Document/getDocumentPermissions.tsx +++ b/packages/next/src/views/Document/getDocumentPermissions.tsx @@ -16,6 +16,9 @@ export const getDocumentPermissions = async (args: { collectionConfig?: SanitizedCollectionConfig data: Data globalConfig?: SanitizedGlobalConfig + /** + * When called for creating a new document, id is not provided. + */ id?: number | string req: PayloadRequest }): Promise<{ diff --git a/packages/payload/src/collections/operations/docAccess.ts b/packages/payload/src/collections/operations/docAccess.ts index 9d793398274..61245dc69e5 100644 --- a/packages/payload/src/collections/operations/docAccess.ts +++ b/packages/payload/src/collections/operations/docAccess.ts @@ -14,7 +14,10 @@ type Arguments = { * If the document data is passed, it will be used to check access instead of fetching the document from the database. */ data?: JsonObject - id: number | string + /** + * When called for creating a new document, id is not provided. + */ + id?: number | string req: PayloadRequest } @@ -42,12 +45,12 @@ export async function docAccessOperation(args: Arguments): Promise 0 const data: JsonObject | Promise | undefined = ( hasData @@ -112,10 +116,6 @@ export async function getEntityPermissions Date: Sun, 16 Nov 2025 22:41:32 -0800 Subject: [PATCH 16/42] fix --- .../src/utilities/getEntityPermissions/getEntityPermissions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts index 9f41b59ae7d..9be6d27f68f 100644 --- a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -117,7 +117,7 @@ export async function getEntityPermissions Date: Mon, 17 Nov 2025 15:06:37 -0800 Subject: [PATCH 17/42] fix(db-mongodb): do not return expired sessions --- packages/db-mongodb/src/utilities/getSession.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/db-mongodb/src/utilities/getSession.ts b/packages/db-mongodb/src/utilities/getSession.ts index 1bb0b8795df..1aefd7a016e 100644 --- a/packages/db-mongodb/src/utilities/getSession.ts +++ b/packages/db-mongodb/src/utilities/getSession.ts @@ -22,6 +22,14 @@ export async function getSession( } if (transactionID) { - return db.sessions[transactionID] + const session = db.sessions[transactionID] + + // Check if session exists and is still in a transaction + // If the session has ended or expired, return undefined to avoid MongoExpiredSessionError + if (session && !session.inTransaction()) { + return undefined + } + + return session } } From db077bee7b8b7d5194514cdc2393fc2c2f2d356f Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 17 Nov 2025 15:23:51 -0800 Subject: [PATCH 18/42] fix transaction issues --- .../src/transactions/commitTransaction.ts | 27 ++++++++++++++----- .../src/transactions/rollbackTransaction.ts | 13 ++++++--- .../db-mongodb/src/utilities/getSession.ts | 11 ++++++-- .../src/transactions/commitTransaction.ts | 19 ++++++++----- .../src/transactions/rollbackTransaction.ts | 9 ++++--- .../drizzle/src/utilities/getTransaction.ts | 3 +++ 6 files changed, 61 insertions(+), 21 deletions(-) diff --git a/packages/db-mongodb/src/transactions/commitTransaction.ts b/packages/db-mongodb/src/transactions/commitTransaction.ts index 6fcbdb838cd..1d07a75975e 100644 --- a/packages/db-mongodb/src/transactions/commitTransaction.ts +++ b/packages/db-mongodb/src/transactions/commitTransaction.ts @@ -4,21 +4,36 @@ import type { MongooseAdapter } from '../index.js' export const commitTransaction: CommitTransaction = async function commitTransaction( this: MongooseAdapter, - id, + incomingID, ) { - if (id instanceof Promise) { + let transactionID: number | string + + if (incomingID instanceof Promise) { + transactionID = await incomingID + } else { + transactionID = incomingID + } + + if (!this.sessions[transactionID]) { return } - if (!this.sessions[id]?.inTransaction()) { + if (!this.sessions[transactionID]?.inTransaction()) { + // Clean up the orphaned session reference + delete this.sessions[transactionID] return } - await this.sessions[id].commitTransaction() + const session = this.sessions[transactionID]! + + // Delete from registry FIRST to prevent race conditions + // This ensures other operations can't retrieve this session while we're ending it + delete this.sessions[transactionID] + + await session.commitTransaction() try { - await this.sessions[id].endSession() + await session.endSession() } catch (_) { // ending sessions is only best effort and won't impact anything if it fails since the transaction was committed } - delete this.sessions[id] } diff --git a/packages/db-mongodb/src/transactions/rollbackTransaction.ts b/packages/db-mongodb/src/transactions/rollbackTransaction.ts index 78c6f353632..692dbf4c774 100644 --- a/packages/db-mongodb/src/transactions/rollbackTransaction.ts +++ b/packages/db-mongodb/src/transactions/rollbackTransaction.ts @@ -27,12 +27,17 @@ export const rollbackTransaction: RollbackTransaction = async function rollbackT return } + const session = this.sessions[transactionID]! + + // Delete from registry FIRST to prevent race conditions + // This ensures other operations can't retrieve this session while we're aborting it + delete this.sessions[transactionID] + // the first call for rollback should be aborted and deleted causing any other operations with the same transaction to fail try { - await this.sessions[transactionID]?.abortTransaction() - await this.sessions[transactionID]?.endSession() - } catch (error) { + await session.abortTransaction() + await session.endSession() + } catch (_error) { // ignore the error as it is likely a race condition from multiple errors } - delete this.sessions[transactionID] } diff --git a/packages/db-mongodb/src/utilities/getSession.ts b/packages/db-mongodb/src/utilities/getSession.ts index 1aefd7a016e..b72d00a82c7 100644 --- a/packages/db-mongodb/src/utilities/getSession.ts +++ b/packages/db-mongodb/src/utilities/getSession.ts @@ -24,9 +24,16 @@ export async function getSession( if (transactionID) { const session = db.sessions[transactionID] - // Check if session exists and is still in a transaction - // If the session has ended or expired, return undefined to avoid MongoExpiredSessionError + // Defensive check for race conditions where: + // 1. Session was retrieved from db.sessions + // 2. Another operation committed/rolled back and ended the session + // 3. This operation tries to use the now-ended session + // Note: This shouldn't normally happen as sessions are deleted from db.sessions + // after commit/rollback, but can occur due to async timing where we hold + // a reference to a session object that gets ended before we use it. if (session && !session.inTransaction()) { + // Clean up the orphaned session reference + delete db.sessions[transactionID] return undefined } diff --git a/packages/drizzle/src/transactions/commitTransaction.ts b/packages/drizzle/src/transactions/commitTransaction.ts index eed2c0f4ecb..1606cd98bb6 100644 --- a/packages/drizzle/src/transactions/commitTransaction.ts +++ b/packages/drizzle/src/transactions/commitTransaction.ts @@ -1,20 +1,27 @@ import type { CommitTransaction } from 'payload' export const commitTransaction: CommitTransaction = async function commitTransaction(id) { + let transactionID: number | string if (id instanceof Promise) { - return + transactionID = await id + } else { + transactionID = id } // if the session was deleted it has already been aborted - if (!this.sessions[id]) { + if (!this.sessions[transactionID]) { return } + const session = this.sessions[transactionID] + + // Delete from registry FIRST to prevent race conditions + // This ensures other operations can't retrieve this session while we're ending it + delete this.sessions[transactionID] + try { - await this.sessions[id].resolve() + await session.resolve() } catch (_) { - await this.sessions[id].reject() + await session.reject() } - - delete this.sessions[id] } diff --git a/packages/drizzle/src/transactions/rollbackTransaction.ts b/packages/drizzle/src/transactions/rollbackTransaction.ts index 143fbefc3f1..75b838604a1 100644 --- a/packages/drizzle/src/transactions/rollbackTransaction.ts +++ b/packages/drizzle/src/transactions/rollbackTransaction.ts @@ -11,9 +11,12 @@ export const rollbackTransaction: RollbackTransaction = async function rollbackT return } - // end the session promise in failure by calling reject - await this.sessions[transactionID].reject() + const session = this.sessions[transactionID] - // delete the session causing any other operations with the same transaction to fail + // Delete from registry FIRST to prevent race conditions + // This ensures other operations can't retrieve this session while we're ending it delete this.sessions[transactionID] + + // end the session promise in failure by calling reject + await session.reject() } diff --git a/packages/drizzle/src/utilities/getTransaction.ts b/packages/drizzle/src/utilities/getTransaction.ts index 910674fd9f7..21662c91439 100644 --- a/packages/drizzle/src/utilities/getTransaction.ts +++ b/packages/drizzle/src/utilities/getTransaction.ts @@ -4,6 +4,9 @@ import type { DrizzleAdapter } from '../types.js' /** * Returns current db transaction instance from req or adapter.drizzle itself + * + * If a transaction session doesn't exist (e.g., it was already committed/rolled back), + * falls back to the default adapter.drizzle instance to prevent errors. */ export const getTransaction = async ( adapter: T, From 584c66be583e55eb1d252d7e9bad2c38fcbccf94 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 17 Nov 2025 15:06:37 -0800 Subject: [PATCH 19/42] fix(db-mongodb): do not return expired sessions --- packages/db-mongodb/src/utilities/getSession.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/db-mongodb/src/utilities/getSession.ts b/packages/db-mongodb/src/utilities/getSession.ts index 1bb0b8795df..1aefd7a016e 100644 --- a/packages/db-mongodb/src/utilities/getSession.ts +++ b/packages/db-mongodb/src/utilities/getSession.ts @@ -22,6 +22,14 @@ export async function getSession( } if (transactionID) { - return db.sessions[transactionID] + const session = db.sessions[transactionID] + + // Check if session exists and is still in a transaction + // If the session has ended or expired, return undefined to avoid MongoExpiredSessionError + if (session && !session.inTransaction()) { + return undefined + } + + return session } } From ea90e08df6869468ff35e2c2dddeffd3917b70d4 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 17 Nov 2025 15:23:51 -0800 Subject: [PATCH 20/42] fix transaction issues --- .../src/transactions/commitTransaction.ts | 27 ++++++++++++++----- .../src/transactions/rollbackTransaction.ts | 13 ++++++--- .../db-mongodb/src/utilities/getSession.ts | 11 ++++++-- .../src/transactions/commitTransaction.ts | 19 ++++++++----- .../src/transactions/rollbackTransaction.ts | 9 ++++--- .../drizzle/src/utilities/getTransaction.ts | 3 +++ 6 files changed, 61 insertions(+), 21 deletions(-) diff --git a/packages/db-mongodb/src/transactions/commitTransaction.ts b/packages/db-mongodb/src/transactions/commitTransaction.ts index 6fcbdb838cd..1d07a75975e 100644 --- a/packages/db-mongodb/src/transactions/commitTransaction.ts +++ b/packages/db-mongodb/src/transactions/commitTransaction.ts @@ -4,21 +4,36 @@ import type { MongooseAdapter } from '../index.js' export const commitTransaction: CommitTransaction = async function commitTransaction( this: MongooseAdapter, - id, + incomingID, ) { - if (id instanceof Promise) { + let transactionID: number | string + + if (incomingID instanceof Promise) { + transactionID = await incomingID + } else { + transactionID = incomingID + } + + if (!this.sessions[transactionID]) { return } - if (!this.sessions[id]?.inTransaction()) { + if (!this.sessions[transactionID]?.inTransaction()) { + // Clean up the orphaned session reference + delete this.sessions[transactionID] return } - await this.sessions[id].commitTransaction() + const session = this.sessions[transactionID]! + + // Delete from registry FIRST to prevent race conditions + // This ensures other operations can't retrieve this session while we're ending it + delete this.sessions[transactionID] + + await session.commitTransaction() try { - await this.sessions[id].endSession() + await session.endSession() } catch (_) { // ending sessions is only best effort and won't impact anything if it fails since the transaction was committed } - delete this.sessions[id] } diff --git a/packages/db-mongodb/src/transactions/rollbackTransaction.ts b/packages/db-mongodb/src/transactions/rollbackTransaction.ts index 78c6f353632..692dbf4c774 100644 --- a/packages/db-mongodb/src/transactions/rollbackTransaction.ts +++ b/packages/db-mongodb/src/transactions/rollbackTransaction.ts @@ -27,12 +27,17 @@ export const rollbackTransaction: RollbackTransaction = async function rollbackT return } + const session = this.sessions[transactionID]! + + // Delete from registry FIRST to prevent race conditions + // This ensures other operations can't retrieve this session while we're aborting it + delete this.sessions[transactionID] + // the first call for rollback should be aborted and deleted causing any other operations with the same transaction to fail try { - await this.sessions[transactionID]?.abortTransaction() - await this.sessions[transactionID]?.endSession() - } catch (error) { + await session.abortTransaction() + await session.endSession() + } catch (_error) { // ignore the error as it is likely a race condition from multiple errors } - delete this.sessions[transactionID] } diff --git a/packages/db-mongodb/src/utilities/getSession.ts b/packages/db-mongodb/src/utilities/getSession.ts index 1aefd7a016e..b72d00a82c7 100644 --- a/packages/db-mongodb/src/utilities/getSession.ts +++ b/packages/db-mongodb/src/utilities/getSession.ts @@ -24,9 +24,16 @@ export async function getSession( if (transactionID) { const session = db.sessions[transactionID] - // Check if session exists and is still in a transaction - // If the session has ended or expired, return undefined to avoid MongoExpiredSessionError + // Defensive check for race conditions where: + // 1. Session was retrieved from db.sessions + // 2. Another operation committed/rolled back and ended the session + // 3. This operation tries to use the now-ended session + // Note: This shouldn't normally happen as sessions are deleted from db.sessions + // after commit/rollback, but can occur due to async timing where we hold + // a reference to a session object that gets ended before we use it. if (session && !session.inTransaction()) { + // Clean up the orphaned session reference + delete db.sessions[transactionID] return undefined } diff --git a/packages/drizzle/src/transactions/commitTransaction.ts b/packages/drizzle/src/transactions/commitTransaction.ts index eed2c0f4ecb..1606cd98bb6 100644 --- a/packages/drizzle/src/transactions/commitTransaction.ts +++ b/packages/drizzle/src/transactions/commitTransaction.ts @@ -1,20 +1,27 @@ import type { CommitTransaction } from 'payload' export const commitTransaction: CommitTransaction = async function commitTransaction(id) { + let transactionID: number | string if (id instanceof Promise) { - return + transactionID = await id + } else { + transactionID = id } // if the session was deleted it has already been aborted - if (!this.sessions[id]) { + if (!this.sessions[transactionID]) { return } + const session = this.sessions[transactionID] + + // Delete from registry FIRST to prevent race conditions + // This ensures other operations can't retrieve this session while we're ending it + delete this.sessions[transactionID] + try { - await this.sessions[id].resolve() + await session.resolve() } catch (_) { - await this.sessions[id].reject() + await session.reject() } - - delete this.sessions[id] } diff --git a/packages/drizzle/src/transactions/rollbackTransaction.ts b/packages/drizzle/src/transactions/rollbackTransaction.ts index 143fbefc3f1..75b838604a1 100644 --- a/packages/drizzle/src/transactions/rollbackTransaction.ts +++ b/packages/drizzle/src/transactions/rollbackTransaction.ts @@ -11,9 +11,12 @@ export const rollbackTransaction: RollbackTransaction = async function rollbackT return } - // end the session promise in failure by calling reject - await this.sessions[transactionID].reject() + const session = this.sessions[transactionID] - // delete the session causing any other operations with the same transaction to fail + // Delete from registry FIRST to prevent race conditions + // This ensures other operations can't retrieve this session while we're ending it delete this.sessions[transactionID] + + // end the session promise in failure by calling reject + await session.reject() } diff --git a/packages/drizzle/src/utilities/getTransaction.ts b/packages/drizzle/src/utilities/getTransaction.ts index 910674fd9f7..21662c91439 100644 --- a/packages/drizzle/src/utilities/getTransaction.ts +++ b/packages/drizzle/src/utilities/getTransaction.ts @@ -4,6 +4,9 @@ import type { DrizzleAdapter } from '../types.js' /** * Returns current db transaction instance from req or adapter.drizzle itself + * + * If a transaction session doesn't exist (e.g., it was already committed/rolled back), + * falls back to the default adapter.drizzle instance to prevent errors. */ export const getTransaction = async ( adapter: T, From 951cc8a92ad95774408818a2f32dfdee7b1fce72 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 17 Nov 2025 15:34:30 -0800 Subject: [PATCH 21/42] simplify --- .../db-mongodb/src/transactions/commitTransaction.ts | 12 +++--------- .../src/transactions/rollbackTransaction.ts | 10 ++-------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/packages/db-mongodb/src/transactions/commitTransaction.ts b/packages/db-mongodb/src/transactions/commitTransaction.ts index 1d07a75975e..c49f1c0eb00 100644 --- a/packages/db-mongodb/src/transactions/commitTransaction.ts +++ b/packages/db-mongodb/src/transactions/commitTransaction.ts @@ -4,15 +4,9 @@ import type { MongooseAdapter } from '../index.js' export const commitTransaction: CommitTransaction = async function commitTransaction( this: MongooseAdapter, - incomingID, + incomingID = '', ) { - let transactionID: number | string - - if (incomingID instanceof Promise) { - transactionID = await incomingID - } else { - transactionID = incomingID - } + const transactionID = incomingID instanceof Promise ? await incomingID : incomingID if (!this.sessions[transactionID]) { return @@ -24,7 +18,7 @@ export const commitTransaction: CommitTransaction = async function commitTransac return } - const session = this.sessions[transactionID]! + const session = this.sessions[transactionID] // Delete from registry FIRST to prevent race conditions // This ensures other operations can't retrieve this session while we're ending it diff --git a/packages/db-mongodb/src/transactions/rollbackTransaction.ts b/packages/db-mongodb/src/transactions/rollbackTransaction.ts index 692dbf4c774..7134054d9c1 100644 --- a/packages/db-mongodb/src/transactions/rollbackTransaction.ts +++ b/packages/db-mongodb/src/transactions/rollbackTransaction.ts @@ -6,13 +6,7 @@ export const rollbackTransaction: RollbackTransaction = async function rollbackT this: MongooseAdapter, incomingID = '', ) { - let transactionID: number | string - - if (incomingID instanceof Promise) { - transactionID = await incomingID - } else { - transactionID = incomingID - } + const transactionID = incomingID instanceof Promise ? await incomingID : incomingID // if multiple operations are using the same transaction, the first will flow through and delete the session. // subsequent calls should be ignored. @@ -27,7 +21,7 @@ export const rollbackTransaction: RollbackTransaction = async function rollbackT return } - const session = this.sessions[transactionID]! + const session = this.sessions[transactionID] // Delete from registry FIRST to prevent race conditions // This ensures other operations can't retrieve this session while we're aborting it From 6245e473e3c06b6679a7cdee73a4b46cbe58afbd Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 17 Nov 2025 15:35:40 -0800 Subject: [PATCH 22/42] cleanup --- .../drizzle/src/transactions/commitTransaction.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/drizzle/src/transactions/commitTransaction.ts b/packages/drizzle/src/transactions/commitTransaction.ts index 1606cd98bb6..d5d0b0a9d36 100644 --- a/packages/drizzle/src/transactions/commitTransaction.ts +++ b/packages/drizzle/src/transactions/commitTransaction.ts @@ -1,12 +1,9 @@ import type { CommitTransaction } from 'payload' -export const commitTransaction: CommitTransaction = async function commitTransaction(id) { - let transactionID: number | string - if (id instanceof Promise) { - transactionID = await id - } else { - transactionID = id - } +export const commitTransaction: CommitTransaction = async function commitTransaction( + incomingID = '', +) { + const transactionID = incomingID instanceof Promise ? await incomingID : incomingID // if the session was deleted it has already been aborted if (!this.sessions[transactionID]) { From dd4cb5ec0f185d489d7d524861ae1f5b78a32333 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 17 Nov 2025 17:03:59 -0800 Subject: [PATCH 23/42] Revert "fix transaction issues" This reverts commit db077bee7b8b7d5194514cdc2393fc2c2f2d356f. --- .../src/transactions/commitTransaction.ts | 27 +++++-------------- .../src/transactions/rollbackTransaction.ts | 13 +++------ .../db-mongodb/src/utilities/getSession.ts | 11 ++------ .../src/transactions/commitTransaction.ts | 19 +++++-------- .../src/transactions/rollbackTransaction.ts | 9 +++---- .../drizzle/src/utilities/getTransaction.ts | 3 --- 6 files changed, 21 insertions(+), 61 deletions(-) diff --git a/packages/db-mongodb/src/transactions/commitTransaction.ts b/packages/db-mongodb/src/transactions/commitTransaction.ts index 1d07a75975e..6fcbdb838cd 100644 --- a/packages/db-mongodb/src/transactions/commitTransaction.ts +++ b/packages/db-mongodb/src/transactions/commitTransaction.ts @@ -4,36 +4,21 @@ import type { MongooseAdapter } from '../index.js' export const commitTransaction: CommitTransaction = async function commitTransaction( this: MongooseAdapter, - incomingID, + id, ) { - let transactionID: number | string - - if (incomingID instanceof Promise) { - transactionID = await incomingID - } else { - transactionID = incomingID - } - - if (!this.sessions[transactionID]) { + if (id instanceof Promise) { return } - if (!this.sessions[transactionID]?.inTransaction()) { - // Clean up the orphaned session reference - delete this.sessions[transactionID] + if (!this.sessions[id]?.inTransaction()) { return } - const session = this.sessions[transactionID]! - - // Delete from registry FIRST to prevent race conditions - // This ensures other operations can't retrieve this session while we're ending it - delete this.sessions[transactionID] - - await session.commitTransaction() + await this.sessions[id].commitTransaction() try { - await session.endSession() + await this.sessions[id].endSession() } catch (_) { // ending sessions is only best effort and won't impact anything if it fails since the transaction was committed } + delete this.sessions[id] } diff --git a/packages/db-mongodb/src/transactions/rollbackTransaction.ts b/packages/db-mongodb/src/transactions/rollbackTransaction.ts index 692dbf4c774..78c6f353632 100644 --- a/packages/db-mongodb/src/transactions/rollbackTransaction.ts +++ b/packages/db-mongodb/src/transactions/rollbackTransaction.ts @@ -27,17 +27,12 @@ export const rollbackTransaction: RollbackTransaction = async function rollbackT return } - const session = this.sessions[transactionID]! - - // Delete from registry FIRST to prevent race conditions - // This ensures other operations can't retrieve this session while we're aborting it - delete this.sessions[transactionID] - // the first call for rollback should be aborted and deleted causing any other operations with the same transaction to fail try { - await session.abortTransaction() - await session.endSession() - } catch (_error) { + await this.sessions[transactionID]?.abortTransaction() + await this.sessions[transactionID]?.endSession() + } catch (error) { // ignore the error as it is likely a race condition from multiple errors } + delete this.sessions[transactionID] } diff --git a/packages/db-mongodb/src/utilities/getSession.ts b/packages/db-mongodb/src/utilities/getSession.ts index b72d00a82c7..1aefd7a016e 100644 --- a/packages/db-mongodb/src/utilities/getSession.ts +++ b/packages/db-mongodb/src/utilities/getSession.ts @@ -24,16 +24,9 @@ export async function getSession( if (transactionID) { const session = db.sessions[transactionID] - // Defensive check for race conditions where: - // 1. Session was retrieved from db.sessions - // 2. Another operation committed/rolled back and ended the session - // 3. This operation tries to use the now-ended session - // Note: This shouldn't normally happen as sessions are deleted from db.sessions - // after commit/rollback, but can occur due to async timing where we hold - // a reference to a session object that gets ended before we use it. + // Check if session exists and is still in a transaction + // If the session has ended or expired, return undefined to avoid MongoExpiredSessionError if (session && !session.inTransaction()) { - // Clean up the orphaned session reference - delete db.sessions[transactionID] return undefined } diff --git a/packages/drizzle/src/transactions/commitTransaction.ts b/packages/drizzle/src/transactions/commitTransaction.ts index 1606cd98bb6..eed2c0f4ecb 100644 --- a/packages/drizzle/src/transactions/commitTransaction.ts +++ b/packages/drizzle/src/transactions/commitTransaction.ts @@ -1,27 +1,20 @@ import type { CommitTransaction } from 'payload' export const commitTransaction: CommitTransaction = async function commitTransaction(id) { - let transactionID: number | string if (id instanceof Promise) { - transactionID = await id - } else { - transactionID = id + return } // if the session was deleted it has already been aborted - if (!this.sessions[transactionID]) { + if (!this.sessions[id]) { return } - const session = this.sessions[transactionID] - - // Delete from registry FIRST to prevent race conditions - // This ensures other operations can't retrieve this session while we're ending it - delete this.sessions[transactionID] - try { - await session.resolve() + await this.sessions[id].resolve() } catch (_) { - await session.reject() + await this.sessions[id].reject() } + + delete this.sessions[id] } diff --git a/packages/drizzle/src/transactions/rollbackTransaction.ts b/packages/drizzle/src/transactions/rollbackTransaction.ts index 75b838604a1..143fbefc3f1 100644 --- a/packages/drizzle/src/transactions/rollbackTransaction.ts +++ b/packages/drizzle/src/transactions/rollbackTransaction.ts @@ -11,12 +11,9 @@ export const rollbackTransaction: RollbackTransaction = async function rollbackT return } - const session = this.sessions[transactionID] + // end the session promise in failure by calling reject + await this.sessions[transactionID].reject() - // Delete from registry FIRST to prevent race conditions - // This ensures other operations can't retrieve this session while we're ending it + // delete the session causing any other operations with the same transaction to fail delete this.sessions[transactionID] - - // end the session promise in failure by calling reject - await session.reject() } diff --git a/packages/drizzle/src/utilities/getTransaction.ts b/packages/drizzle/src/utilities/getTransaction.ts index 21662c91439..910674fd9f7 100644 --- a/packages/drizzle/src/utilities/getTransaction.ts +++ b/packages/drizzle/src/utilities/getTransaction.ts @@ -4,9 +4,6 @@ import type { DrizzleAdapter } from '../types.js' /** * Returns current db transaction instance from req or adapter.drizzle itself - * - * If a transaction session doesn't exist (e.g., it was already committed/rolled back), - * falls back to the default adapter.drizzle instance to prevent errors. */ export const getTransaction = async ( adapter: T, From d10dfb2bef854992e45d93c95186497eec3bba51 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 17 Nov 2025 17:04:02 -0800 Subject: [PATCH 24/42] Revert "fix(db-mongodb): do not return expired sessions" This reverts commit fb7674fdb35cdb4db15df7de865a102a0601cfbb. --- packages/db-mongodb/src/utilities/getSession.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/db-mongodb/src/utilities/getSession.ts b/packages/db-mongodb/src/utilities/getSession.ts index 1aefd7a016e..1bb0b8795df 100644 --- a/packages/db-mongodb/src/utilities/getSession.ts +++ b/packages/db-mongodb/src/utilities/getSession.ts @@ -22,14 +22,6 @@ export async function getSession( } if (transactionID) { - const session = db.sessions[transactionID] - - // Check if session exists and is still in a transaction - // If the session has ended or expired, return undefined to avoid MongoExpiredSessionError - if (session && !session.inTransaction()) { - return undefined - } - - return session + return db.sessions[transactionID] } } From 744ca19f012ca4be58d53c11752fb912b3c57ca1 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 17 Nov 2025 19:25:13 -0800 Subject: [PATCH 25/42] fix: transaction IDs shared between operations that are supposed to be separate, due to req caching --- packages/next/src/utilities/initReq.ts | 84 +++++++++++-------- .../payload/src/utilities/createLocalReq.ts | 5 ++ 2 files changed, 52 insertions(+), 37 deletions(-) diff --git a/packages/next/src/utilities/initReq.ts b/packages/next/src/utilities/initReq.ts index e2e4c7cbab9..6f6628d6771 100644 --- a/packages/next/src/utilities/initReq.ts +++ b/packages/next/src/utilities/initReq.ts @@ -94,43 +94,53 @@ export const initReq = async function ({ } }, 'global') - return reqCache.get(async () => { - const { i18n, languageCode, payload, responseHeaders, user } = partialResult - - const { req: reqOverrides, ...optionsOverrides } = overrides || {} - - const req = await createLocalReq( - { - req: { - headers, - host: headers.get('host'), - i18n: i18n as I18n, - responseHeaders, - user, - ...(reqOverrides || {}), + return reqCache + .get(async () => { + const { i18n, languageCode, payload, responseHeaders, user } = partialResult + + const { req: reqOverrides, ...optionsOverrides } = overrides || {} + + const req = await createLocalReq( + { + req: { + headers, + host: headers.get('host'), + i18n: i18n as I18n, + responseHeaders, + user, + ...(reqOverrides || {}), + }, + ...(optionsOverrides || {}), }, - ...(optionsOverrides || {}), - }, - payload, - ) - - const locale = await getRequestLocale({ - req, - }) - - req.locale = locale?.code - - const permissions = await getAccessResults({ - req, + payload, + ) + + const locale = await getRequestLocale({ + req, + }) + + req.locale = locale?.code + + const permissions = await getAccessResults({ + req, + }) + + return { + cookies, + headers, + languageCode, + locale, + permissions, + req, + } + }, key) + .then((result) => { + // CRITICAL: Create a shallow copy of req before returning to prevent + // mutations from propagating to the cached req object. + // This ensures parallel operations using the same cache key don't affect each other. + return { + ...result, + req: { ...result.req }, + } }) - - return { - cookies, - headers, - languageCode, - locale, - permissions, - req, - } - }, key) } diff --git a/packages/payload/src/utilities/createLocalReq.ts b/packages/payload/src/utilities/createLocalReq.ts index 0bfe979984e..26d9af6a96b 100644 --- a/packages/payload/src/utilities/createLocalReq.ts +++ b/packages/payload/src/utilities/createLocalReq.ts @@ -99,6 +99,11 @@ export const createLocalReq: CreateLocalReq = async ( { context, fallbackLocale, locale: localeArg, req = {} as PayloadRequest, urlSuffix, user }, payload, ): Promise => { + // CRITICAL: Create a shallow copy of req to prevent mutations from propagating + // to the original req object (which may be cached or shared across operations) + // This preserves any intentional transactionID while preventing mutation leakage + req = { ...req } + const localization = payload.config?.localization if (localization) { From 015591a6a1a27ac7082aa6f256ea1d8e3f78f48e Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 17 Nov 2025 19:47:22 -0800 Subject: [PATCH 26/42] fix: move session find as close to db call as possible --- packages/db-mongodb/src/count.ts | 8 +++--- .../db-mongodb/src/countGlobalVersions.ts | 8 +++--- packages/db-mongodb/src/countVersions.ts | 8 +++--- packages/db-mongodb/src/create.ts | 12 ++++---- .../db-mongodb/src/createGlobalVersion.ts | 12 ++++---- packages/db-mongodb/src/createVersion.ts | 12 ++++---- packages/db-mongodb/src/deleteMany.ts | 8 +++--- packages/db-mongodb/src/deleteOne.ts | 14 +++++----- packages/db-mongodb/src/deleteVersions.ts | 4 +-- packages/db-mongodb/src/find.ts | 4 +-- packages/db-mongodb/src/findDistinct.ts | 4 +-- packages/db-mongodb/src/findGlobal.ts | 17 +++++------ packages/db-mongodb/src/findGlobalVersions.ts | 14 +++++----- packages/db-mongodb/src/findOne.ts | 12 ++++---- packages/db-mongodb/src/findVersions.ts | 14 +++++----- packages/db-mongodb/src/queryDrafts.ts | 10 ++++--- packages/db-mongodb/src/updateGlobal.ts | 4 +-- .../db-mongodb/src/updateGlobalVersion.ts | 19 +++++++------ packages/db-mongodb/src/updateJobs.ts | 16 +++++------ packages/db-mongodb/src/updateMany.ts | 28 +++++++++---------- packages/db-mongodb/src/updateOne.ts | 28 +++++++++---------- packages/db-mongodb/src/updateVersion.ts | 18 ++++++------ packages/drizzle/src/count.ts | 4 +-- packages/drizzle/src/countGlobalVersions.ts | 4 +-- packages/drizzle/src/countVersions.ts | 4 +-- packages/drizzle/src/create.ts | 3 +- packages/drizzle/src/createGlobal.ts | 3 +- packages/drizzle/src/createGlobalVersion.ts | 3 +- packages/drizzle/src/createVersion.ts | 3 +- packages/drizzle/src/deleteMany.ts | 3 +- packages/drizzle/src/deleteOne.ts | 3 +- packages/drizzle/src/deleteVersions.ts | 4 +-- packages/drizzle/src/find/findMany.ts | 3 +- packages/drizzle/src/findDistinct.ts | 3 +- packages/drizzle/src/updateGlobal.ts | 2 +- packages/drizzle/src/updateGlobalVersion.ts | 3 +- packages/drizzle/src/updateJobs.ts | 5 +++- packages/drizzle/src/updateMany.ts | 3 +- packages/drizzle/src/updateOne.ts | 3 +- packages/drizzle/src/updateVersion.ts | 3 +- 40 files changed, 176 insertions(+), 157 deletions(-) diff --git a/packages/db-mongodb/src/count.ts b/packages/db-mongodb/src/count.ts index fd0aa284d20..e88fb5e59c6 100644 --- a/packages/db-mongodb/src/count.ts +++ b/packages/db-mongodb/src/count.ts @@ -15,10 +15,6 @@ export const count: Count = async function count( ) { const { collectionConfig, Model } = getCollection({ adapter: this, collectionSlug }) - const options: CountOptions = { - session: await getSession(this, req), - } - let hasNearConstraint = false if (where) { @@ -37,6 +33,10 @@ export const count: Count = async function count( // useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters. const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0 + const options: CountOptions = { + session: await getSession(this, req), + } + if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) { // Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding // a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents, diff --git a/packages/db-mongodb/src/countGlobalVersions.ts b/packages/db-mongodb/src/countGlobalVersions.ts index c91f1c8fd85..0d4ff89a298 100644 --- a/packages/db-mongodb/src/countGlobalVersions.ts +++ b/packages/db-mongodb/src/countGlobalVersions.ts @@ -15,10 +15,6 @@ export const countGlobalVersions: CountGlobalVersions = async function countGlob ) { const { globalConfig, Model } = getGlobal({ adapter: this, globalSlug, versions: true }) - const options: CountOptions = { - session: await getSession(this, req), - } - let hasNearConstraint = false if (where) { @@ -36,6 +32,10 @@ export const countGlobalVersions: CountGlobalVersions = async function countGlob // useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters. const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0 + const options: CountOptions = { + session: await getSession(this, req), + } + if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) { // Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding // a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents, diff --git a/packages/db-mongodb/src/countVersions.ts b/packages/db-mongodb/src/countVersions.ts index bb31931cb00..d3ba5690160 100644 --- a/packages/db-mongodb/src/countVersions.ts +++ b/packages/db-mongodb/src/countVersions.ts @@ -19,10 +19,6 @@ export const countVersions: CountVersions = async function countVersions( versions: true, }) - const options: CountOptions = { - session: await getSession(this, req), - } - let hasNearConstraint = false if (where) { @@ -40,6 +36,10 @@ export const countVersions: CountVersions = async function countVersions( // useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters. const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0 + const options: CountOptions = { + session: await getSession(this, req), + } + if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) { // Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding // a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents, diff --git a/packages/db-mongodb/src/create.ts b/packages/db-mongodb/src/create.ts index 82e65fcd702..9c96cd32467 100644 --- a/packages/db-mongodb/src/create.ts +++ b/packages/db-mongodb/src/create.ts @@ -15,12 +15,6 @@ export const create: Create = async function create( ) { const { collectionConfig, customIDType, Model } = getCollection({ adapter: this, collectionSlug }) - const options: CreateOptions = { - session: await getSession(this, req), - // Timestamps are manually added by the write transform - timestamps: false, - } - let doc if (!data.createdAt) { @@ -47,6 +41,12 @@ export const create: Create = async function create( } } + const options: CreateOptions = { + session: await getSession(this, req), + // Timestamps are manually added by the write transform + timestamps: false, + } + try { ;[doc] = await Model.create([data], options) } catch (error) { diff --git a/packages/db-mongodb/src/createGlobalVersion.ts b/packages/db-mongodb/src/createGlobalVersion.ts index 0469d919e39..64104d6421d 100644 --- a/packages/db-mongodb/src/createGlobalVersion.ts +++ b/packages/db-mongodb/src/createGlobalVersion.ts @@ -22,12 +22,6 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo ) { const { globalConfig, Model } = getGlobal({ adapter: this, globalSlug, versions: true }) - const options = { - session: await getSession(this, req), - // Timestamps are manually added by the write transform - timestamps: false, - } - const data = { autosave, createdAt, @@ -50,6 +44,12 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo operation: 'write', }) + const options = { + session: await getSession(this, req), + // Timestamps are manually added by the write transform + timestamps: false, + } + let [doc] = await Model.create([data], options, req) await Model.updateMany( diff --git a/packages/db-mongodb/src/createVersion.ts b/packages/db-mongodb/src/createVersion.ts index a6a7b5c2c20..aba93c496ad 100644 --- a/packages/db-mongodb/src/createVersion.ts +++ b/packages/db-mongodb/src/createVersion.ts @@ -27,12 +27,6 @@ export const createVersion: CreateVersion = async function createVersion( versions: true, }) - const options = { - session: await getSession(this, req), - // Timestamps are manually added by the write transform - timestamps: false, - } - const data = { autosave, createdAt, @@ -56,6 +50,12 @@ export const createVersion: CreateVersion = async function createVersion( operation: 'write', }) + const options = { + session: await getSession(this, req), + // Timestamps are manually added by the write transform + timestamps: false, + } + let [doc] = await Model.create([data], options, req) const parentQuery = { diff --git a/packages/db-mongodb/src/deleteMany.ts b/packages/db-mongodb/src/deleteMany.ts index a40fc7fb610..ebaee1df3f5 100644 --- a/packages/db-mongodb/src/deleteMany.ts +++ b/packages/db-mongodb/src/deleteMany.ts @@ -14,10 +14,6 @@ export const deleteMany: DeleteMany = async function deleteMany( ) { const { collectionConfig, Model } = getCollection({ adapter: this, collectionSlug }) - const options: DeleteOptions = { - session: await getSession(this, req), - } - const query = await buildQuery({ adapter: this, collectionSlug, @@ -25,5 +21,9 @@ export const deleteMany: DeleteMany = async function deleteMany( where, }) + const options: DeleteOptions = { + session: await getSession(this, req), + } + await Model.deleteMany(query, options) } diff --git a/packages/db-mongodb/src/deleteOne.ts b/packages/db-mongodb/src/deleteOne.ts index 1731642780f..713b8d9fea0 100644 --- a/packages/db-mongodb/src/deleteOne.ts +++ b/packages/db-mongodb/src/deleteOne.ts @@ -15,6 +15,13 @@ export const deleteOne: DeleteOne = async function deleteOne( ) { const { collectionConfig, Model } = getCollection({ adapter: this, collectionSlug }) + const query = await buildQuery({ + adapter: this, + collectionSlug, + fields: collectionConfig.flattenedFields, + where, + }) + const options: MongooseUpdateQueryOptions = { projection: buildProjectionFromSelect({ adapter: this, @@ -24,13 +31,6 @@ export const deleteOne: DeleteOne = async function deleteOne( session: await getSession(this, req), } - const query = await buildQuery({ - adapter: this, - collectionSlug, - fields: collectionConfig.flattenedFields, - where, - }) - if (returning === false) { await Model.deleteOne(query, options)?.lean() return null diff --git a/packages/db-mongodb/src/deleteVersions.ts b/packages/db-mongodb/src/deleteVersions.ts index 1d311b4f34c..97436494d78 100644 --- a/packages/db-mongodb/src/deleteVersions.ts +++ b/packages/db-mongodb/src/deleteVersions.ts @@ -36,8 +36,6 @@ export const deleteVersions: DeleteVersions = async function deleteVersions( throw new APIError('Either collection or globalSlug must be passed.') } - const session = await getSession(this, req) - const query = await buildQuery({ adapter: this, fields, @@ -45,5 +43,7 @@ export const deleteVersions: DeleteVersions = async function deleteVersions( where, }) + const session = await getSession(this, req) + await VersionsModel.deleteMany(query, { session }) } diff --git a/packages/db-mongodb/src/find.ts b/packages/db-mongodb/src/find.ts index 907bd744844..c3e655f9f4f 100644 --- a/packages/db-mongodb/src/find.ts +++ b/packages/db-mongodb/src/find.ts @@ -34,8 +34,6 @@ export const find: Find = async function find( ) { const { collectionConfig, Model } = getCollection({ adapter: this, collectionSlug }) - const session = await getSession(this, req) - let hasNearConstraint = false if (where) { @@ -66,6 +64,8 @@ export const find: Find = async function find( where, }) + const session = await getSession(this, req) + // useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters. const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0 const paginationOptions: PaginateOptions = { diff --git a/packages/db-mongodb/src/findDistinct.ts b/packages/db-mongodb/src/findDistinct.ts index f72977eb2a6..66943f06525 100644 --- a/packages/db-mongodb/src/findDistinct.ts +++ b/packages/db-mongodb/src/findDistinct.ts @@ -16,8 +16,6 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter, collectionSlug: args.collection, }) - const session = await getSession(this, args.req) - const { where = {} } = args let sortAggregation: PipelineStage[] = [] @@ -202,6 +200,8 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter, }, ] + const session = await getSession(this, args.req) + const getValues = async () => { return Model.aggregate(pipeline, { session }).then((res) => res.map((each) => ({ diff --git a/packages/db-mongodb/src/findGlobal.ts b/packages/db-mongodb/src/findGlobal.ts index 18e3ad5eb68..c87fea72da9 100644 --- a/packages/db-mongodb/src/findGlobal.ts +++ b/packages/db-mongodb/src/findGlobal.ts @@ -18,6 +18,15 @@ export const findGlobal: FindGlobal = async function findGlobal( const { globalConfig, Model } = getGlobal({ adapter: this, globalSlug }) const fields = globalConfig.flattenedFields + + const query = await buildQuery({ + adapter: this, + fields, + globalSlug, + locale, + where: combineQueries({ globalType: { equals: globalSlug } }, where), + }) + const options: QueryOptions = { lean: true, select: buildProjectionFromSelect({ @@ -28,14 +37,6 @@ export const findGlobal: FindGlobal = async function findGlobal( session: await getSession(this, req), } - const query = await buildQuery({ - adapter: this, - fields, - globalSlug, - locale, - where: combineQueries({ globalType: { equals: globalSlug } }, where), - }) - const doc: any = await Model.findOne(query, {}, options) if (!doc) { diff --git a/packages/db-mongodb/src/findGlobalVersions.ts b/packages/db-mongodb/src/findGlobalVersions.ts index 76e9bc5fbc9..f85aafa3567 100644 --- a/packages/db-mongodb/src/findGlobalVersions.ts +++ b/packages/db-mongodb/src/findGlobalVersions.ts @@ -31,13 +31,6 @@ export const findGlobalVersions: FindGlobalVersions = async function findGlobalV const versionFields = buildVersionGlobalFields(this.payload.config, globalConfig, true) - const session = await getSession(this, req) - const options: QueryOptions = { - limit, - session, - skip, - } - let hasNearConstraint = false if (where) { @@ -64,6 +57,13 @@ export const findGlobalVersions: FindGlobalVersions = async function findGlobalV where, }) + const session = await getSession(this, req) + const options: QueryOptions = { + limit, + session, + skip, + } + // useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters. const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0 const paginationOptions: PaginateOptions = { diff --git a/packages/db-mongodb/src/findOne.ts b/packages/db-mongodb/src/findOne.ts index cf6edb34f05..6abf81bdcbc 100644 --- a/packages/db-mongodb/src/findOne.ts +++ b/packages/db-mongodb/src/findOne.ts @@ -19,12 +19,6 @@ export const findOne: FindOne = async function findOne( ) { const { collectionConfig, Model } = getCollection({ adapter: this, collectionSlug }) - const session = await getSession(this, req) - const options: AggregateOptions & QueryOptions = { - lean: true, - session, - } - const query = await buildQuery({ adapter: this, collectionSlug, @@ -50,6 +44,12 @@ export const findOne: FindOne = async function findOne( query, }) + const session = await getSession(this, req) + const options: AggregateOptions & QueryOptions = { + lean: true, + session, + } + let doc if (aggregate) { const { docs } = await aggregatePaginate({ diff --git a/packages/db-mongodb/src/findVersions.ts b/packages/db-mongodb/src/findVersions.ts index 85be3661106..4b30753406b 100644 --- a/packages/db-mongodb/src/findVersions.ts +++ b/packages/db-mongodb/src/findVersions.ts @@ -33,13 +33,6 @@ export const findVersions: FindVersions = async function findVersions( versions: true, }) - const session = await getSession(this, req) - const options: QueryOptions = { - limit, - session, - skip, - } - let hasNearConstraint = false if (where) { @@ -68,6 +61,13 @@ export const findVersions: FindVersions = async function findVersions( where, }) + const session = await getSession(this, req) + const options: QueryOptions = { + limit, + session, + skip, + } + // useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters. const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0 const paginationOptions: PaginateOptions = { diff --git a/packages/db-mongodb/src/queryDrafts.ts b/packages/db-mongodb/src/queryDrafts.ts index 1dd0e84dafd..7af5faaa78a 100644 --- a/packages/db-mongodb/src/queryDrafts.ts +++ b/packages/db-mongodb/src/queryDrafts.ts @@ -36,10 +36,6 @@ export const queryDrafts: QueryDrafts = async function queryDrafts( versions: true, }) - const options: QueryOptions = { - session: await getSession(this, req), - } - let hasNearConstraint let sort @@ -78,6 +74,12 @@ export const queryDrafts: QueryDrafts = async function queryDrafts( fields, select, }) + + const session = await getSession(this, req) + const options: QueryOptions = { + session, + } + // useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters. const useEstimatedCount = hasNearConstraint || !versionQuery || Object.keys(versionQuery).length === 0 diff --git a/packages/db-mongodb/src/updateGlobal.ts b/packages/db-mongodb/src/updateGlobal.ts index 2ed7aa17069..8cdb644ac3d 100644 --- a/packages/db-mongodb/src/updateGlobal.ts +++ b/packages/db-mongodb/src/updateGlobal.ts @@ -16,6 +16,8 @@ export const updateGlobal: UpdateGlobal = async function updateGlobal( const fields = globalConfig.fields + transform({ adapter: this, data, fields, globalSlug, operation: 'write' }) + const options: MongooseUpdateQueryOptions = { ...optionsArgs, lean: true, @@ -30,8 +32,6 @@ export const updateGlobal: UpdateGlobal = async function updateGlobal( timestamps: false, } - transform({ adapter: this, data, fields, globalSlug, operation: 'write' }) - if (returning === false) { await Model.updateOne({ globalType: globalSlug }, data, options) return null diff --git a/packages/db-mongodb/src/updateGlobalVersion.ts b/packages/db-mongodb/src/updateGlobalVersion.ts index 0ec825b5a57..c924e4803bd 100644 --- a/packages/db-mongodb/src/updateGlobalVersion.ts +++ b/packages/db-mongodb/src/updateGlobalVersion.ts @@ -30,6 +30,16 @@ export async function updateGlobalVersion( const fields = buildVersionGlobalFields(this.payload.config, globalConfig) const flattenedFields = buildVersionGlobalFields(this.payload.config, globalConfig, true) + + const query = await buildQuery({ + adapter: this, + fields: flattenedFields, + locale, + where: whereToUse, + }) + + transform({ adapter: this, data: versionData, fields, operation: 'write' }) + const options: MongooseUpdateQueryOptions = { ...optionsArgs, lean: true, @@ -44,15 +54,6 @@ export async function updateGlobalVersion( timestamps: false, } - const query = await buildQuery({ - adapter: this, - fields: flattenedFields, - locale, - where: whereToUse, - }) - - transform({ adapter: this, data: versionData, fields, operation: 'write' }) - if (returning === false) { await Model.updateOne(query, versionData, options) return null diff --git a/packages/db-mongodb/src/updateJobs.ts b/packages/db-mongodb/src/updateJobs.ts index 956d32b4699..5de930263ff 100644 --- a/packages/db-mongodb/src/updateJobs.ts +++ b/packages/db-mongodb/src/updateJobs.ts @@ -36,14 +36,6 @@ export const updateJobs: UpdateJobs = async function updateMany( timestamps: true, }) - const options: MongooseUpdateQueryOptions = { - lean: true, - new: true, - session: await getSession(this, req), - // Timestamps are manually added by the write transform - timestamps: false, - } - let query = await buildQuery({ adapter: this, collectionSlug: collectionConfig.slug, @@ -88,6 +80,14 @@ export const updateJobs: UpdateJobs = async function updateMany( updateData = updateOps } + const options: MongooseUpdateQueryOptions = { + lean: true, + new: true, + session: await getSession(this, req), + // Timestamps are manually added by the write transform + timestamps: false, + } + let result: Job[] = [] try { diff --git a/packages/db-mongodb/src/updateMany.ts b/packages/db-mongodb/src/updateMany.ts index 850a99c4c60..192322a89ac 100644 --- a/packages/db-mongodb/src/updateMany.ts +++ b/packages/db-mongodb/src/updateMany.ts @@ -48,20 +48,6 @@ export const updateMany: UpdateMany = async function updateMany( }) } - const options: MongooseUpdateQueryOptions = { - ...optionsArgs, - lean: true, - new: true, - projection: buildProjectionFromSelect({ - adapter: this, - fields: collectionConfig.flattenedFields, - select, - }), - session: await getSession(this, req), - // Timestamps are manually added by the write transform - timestamps: false, - } - let query = await buildQuery({ adapter: this, collectionSlug, @@ -105,6 +91,20 @@ export const updateMany: UpdateMany = async function updateMany( data = updateOps } + const options: MongooseUpdateQueryOptions = { + ...optionsArgs, + lean: true, + new: true, + projection: buildProjectionFromSelect({ + adapter: this, + fields: collectionConfig.flattenedFields, + select, + }), + session: await getSession(this, req), + // Timestamps are manually added by the write transform + timestamps: false, + } + try { if (typeof limit === 'number' && limit > 0) { const documentsToUpdate = await Model.find( diff --git a/packages/db-mongodb/src/updateOne.ts b/packages/db-mongodb/src/updateOne.ts index 938a6eac53f..53c2ab0ee63 100644 --- a/packages/db-mongodb/src/updateOne.ts +++ b/packages/db-mongodb/src/updateOne.ts @@ -28,20 +28,6 @@ export const updateOne: UpdateOne = async function updateOne( const where = id ? { id: { equals: id } } : whereArg const fields = collectionConfig.fields - const options: MongooseUpdateQueryOptions = { - ...optionsArgs, - lean: true, - new: true, - projection: buildProjectionFromSelect({ - adapter: this, - fields: collectionConfig.flattenedFields, - select, - }), - session: await getSession(this, req), - // Timestamps are manually added by the write transform - timestamps: false, - } - const query = await buildQuery({ adapter: this, collectionSlug, @@ -89,6 +75,20 @@ export const updateOne: UpdateOne = async function updateOne( updateData = updateOps } + const options: MongooseUpdateQueryOptions = { + ...optionsArgs, + lean: true, + new: true, + projection: buildProjectionFromSelect({ + adapter: this, + fields: collectionConfig.flattenedFields, + select, + }), + session: await getSession(this, req), + // Timestamps are manually added by the write transform + timestamps: false, + } + try { if (returning === false) { await Model.updateOne(query, updateData, options) diff --git a/packages/db-mongodb/src/updateVersion.ts b/packages/db-mongodb/src/updateVersion.ts index 4b2cbc5473f..94241118604 100644 --- a/packages/db-mongodb/src/updateVersion.ts +++ b/packages/db-mongodb/src/updateVersion.ts @@ -35,6 +35,15 @@ export const updateVersion: UpdateVersion = async function updateVersion( const flattenedFields = buildVersionCollectionFields(this.payload.config, collectionConfig, true) + const query = await buildQuery({ + adapter: this, + fields: flattenedFields, + locale, + where: whereToUse, + }) + + transform({ adapter: this, data: versionData, fields, operation: 'write' }) + const options: MongooseUpdateQueryOptions = { ...optionsArgs, lean: true, @@ -49,15 +58,6 @@ export const updateVersion: UpdateVersion = async function updateVersion( timestamps: false, } - const query = await buildQuery({ - adapter: this, - fields: flattenedFields, - locale, - where: whereToUse, - }) - - transform({ adapter: this, data: versionData, fields, operation: 'write' }) - if (returning === false) { await Model.updateOne(query, versionData, options) return null diff --git a/packages/drizzle/src/count.ts b/packages/drizzle/src/count.ts index b7e415e27a1..b5ee8d15d86 100644 --- a/packages/drizzle/src/count.ts +++ b/packages/drizzle/src/count.ts @@ -15,8 +15,6 @@ export const count: Count = async function count( const tableName = this.tableNameMap.get(toSnakeCase(collectionConfig.slug)) - const db = await getTransaction(this, req) - const { joins, where } = buildQuery({ adapter: this, fields: collectionConfig.flattenedFields, @@ -25,6 +23,8 @@ export const count: Count = async function count( where: whereArg, }) + const db = await getTransaction(this, req) + const countResult = await this.countDistinct({ db, joins, diff --git a/packages/drizzle/src/countGlobalVersions.ts b/packages/drizzle/src/countGlobalVersions.ts index 4e9e7a86bb8..0552d65fe3b 100644 --- a/packages/drizzle/src/countGlobalVersions.ts +++ b/packages/drizzle/src/countGlobalVersions.ts @@ -20,8 +20,6 @@ export const countGlobalVersions: CountGlobalVersions = async function countGlob `_${toSnakeCase(globalConfig.slug)}${this.versionsSuffix}`, ) - const db = await getTransaction(this, req) - const fields = buildVersionGlobalFields(this.payload.config, globalConfig, true) const { joins, where } = buildQuery({ @@ -32,6 +30,8 @@ export const countGlobalVersions: CountGlobalVersions = async function countGlob where: whereArg, }) + const db = await getTransaction(this, req) + const countResult = await this.countDistinct({ db, joins, diff --git a/packages/drizzle/src/countVersions.ts b/packages/drizzle/src/countVersions.ts index 5ba2e2fc119..272c1fd6999 100644 --- a/packages/drizzle/src/countVersions.ts +++ b/packages/drizzle/src/countVersions.ts @@ -18,8 +18,6 @@ export const countVersions: CountVersions = async function countVersions( `_${toSnakeCase(collectionConfig.slug)}${this.versionsSuffix}`, ) - const db = await getTransaction(this, req) - const fields = buildVersionCollectionFields(this.payload.config, collectionConfig, true) const { joins, where } = buildQuery({ @@ -30,6 +28,8 @@ export const countVersions: CountVersions = async function countVersions( where: whereArg, }) + const db = await getTransaction(this, req) + const countResult = await this.countDistinct({ db, joins, diff --git a/packages/drizzle/src/create.ts b/packages/drizzle/src/create.ts index 3901cf8a10a..4ae8997e6c2 100644 --- a/packages/drizzle/src/create.ts +++ b/packages/drizzle/src/create.ts @@ -11,11 +11,12 @@ export const create: Create = async function create( this: DrizzleAdapter, { collection: collectionSlug, data, req, returning, select }, ) { - const db = await getTransaction(this, req) const collection = this.payload.collections[collectionSlug].config const tableName = this.tableNameMap.get(toSnakeCase(collection.slug)) + const db = await getTransaction(this, req) + const result = await upsertRow({ adapter: this, data, diff --git a/packages/drizzle/src/createGlobal.ts b/packages/drizzle/src/createGlobal.ts index 8d1fd0c2d69..d04188b3d53 100644 --- a/packages/drizzle/src/createGlobal.ts +++ b/packages/drizzle/src/createGlobal.ts @@ -11,13 +11,14 @@ export async function createGlobal>( this: DrizzleAdapter, { slug, data, req, returning }: CreateGlobalArgs, ): Promise { - const db = await getTransaction(this, req) const globalConfig = this.payload.globals.config.find((config) => config.slug === slug) const tableName = this.tableNameMap.get(toSnakeCase(globalConfig.slug)) data.createdAt = new Date().toISOString() + const db = await getTransaction(this, req) + const result = await upsertRow<{ globalType: string } & T>({ adapter: this, data, diff --git a/packages/drizzle/src/createGlobalVersion.ts b/packages/drizzle/src/createGlobalVersion.ts index b9382acc44d..8b7371d9ff6 100644 --- a/packages/drizzle/src/createGlobalVersion.ts +++ b/packages/drizzle/src/createGlobalVersion.ts @@ -24,11 +24,12 @@ export async function createGlobalVersion( versionData, }: CreateGlobalVersionArgs, ): Promise> { - const db = await getTransaction(this, req) const global = this.payload.globals.config.find(({ slug }) => slug === globalSlug) const tableName = this.tableNameMap.get(`_${toSnakeCase(global.slug)}${this.versionsSuffix}`) + const db = await getTransaction(this, req) + const result = await upsertRow>({ adapter: this, data: { diff --git a/packages/drizzle/src/createVersion.ts b/packages/drizzle/src/createVersion.ts index a76152c0273..a7a1dc7eeab 100644 --- a/packages/drizzle/src/createVersion.ts +++ b/packages/drizzle/src/createVersion.ts @@ -25,7 +25,6 @@ export async function createVersion( versionData, }: CreateVersionArgs, ): Promise> { - const db = await getTransaction(this, req) const collection = this.payload.collections[collectionSlug].config const defaultTableName = toSnakeCase(collection.slug) @@ -47,6 +46,8 @@ export async function createVersion( version, } + const db = await getTransaction(this, req) + const result = await upsertRow>({ adapter: this, data, diff --git a/packages/drizzle/src/deleteMany.ts b/packages/drizzle/src/deleteMany.ts index 9e10290a30b..7e191abc41b 100644 --- a/packages/drizzle/src/deleteMany.ts +++ b/packages/drizzle/src/deleteMany.ts @@ -13,7 +13,6 @@ export const deleteMany: DeleteMany = async function deleteMany( this: DrizzleAdapter, { collection, req, where: whereArg }, ) { - const db = await getTransaction(this, req) const collectionConfig = this.payload.collections[collection].config const tableName = this.tableNameMap.get(toSnakeCase(collectionConfig.slug)) @@ -55,6 +54,8 @@ export const deleteMany: DeleteMany = async function deleteMany( ) } + const db = await getTransaction(this, req) + await this.deleteWhere({ db, tableName, diff --git a/packages/drizzle/src/deleteOne.ts b/packages/drizzle/src/deleteOne.ts index ab36edfcf1d..c29170e1607 100644 --- a/packages/drizzle/src/deleteOne.ts +++ b/packages/drizzle/src/deleteOne.ts @@ -15,7 +15,6 @@ export const deleteOne: DeleteOne = async function deleteOne( this: DrizzleAdapter, { collection: collectionSlug, req, returning, select, where: whereArg }, ) { - const db = await getTransaction(this, req) const collection = this.payload.collections[collectionSlug].config const tableName = this.tableNameMap.get(toSnakeCase(collection.slug)) @@ -30,6 +29,8 @@ export const deleteOne: DeleteOne = async function deleteOne( where: whereArg, }) + const db = await getTransaction(this, req) + const selectDistinctResult = await selectDistinct({ adapter: this, db, diff --git a/packages/drizzle/src/deleteVersions.ts b/packages/drizzle/src/deleteVersions.ts index a0aba3a6970..2f0df95251a 100644 --- a/packages/drizzle/src/deleteVersions.ts +++ b/packages/drizzle/src/deleteVersions.ts @@ -13,8 +13,6 @@ export const deleteVersions: DeleteVersions = async function deleteVersion( this: DrizzleAdapter, { collection: collectionSlug, globalSlug, locale, req, where: where }, ) { - const db = await getTransaction(this, req) - let tableName: string let fields: FlattenedField[] @@ -53,6 +51,8 @@ export const deleteVersions: DeleteVersions = async function deleteVersion( }) if (ids.length > 0) { + const db = await getTransaction(this, req) + await this.deleteWhere({ db, tableName, diff --git a/packages/drizzle/src/find/findMany.ts b/packages/drizzle/src/find/findMany.ts index e991b55197a..167f96f3a76 100644 --- a/packages/drizzle/src/find/findMany.ts +++ b/packages/drizzle/src/find/findMany.ts @@ -37,7 +37,6 @@ export const findMany = async function find({ versions, where: whereArg, }: Args) { - const db = await getTransaction(adapter, req) let limit = limitArg let totalDocs: number let totalPages: number @@ -96,6 +95,8 @@ export const findMany = async function find({ } } + const db = await getTransaction(adapter, req) + const selectDistinctResult = await selectDistinct({ adapter, db, diff --git a/packages/drizzle/src/findDistinct.ts b/packages/drizzle/src/findDistinct.ts index a7876f36623..0759d801603 100644 --- a/packages/drizzle/src/findDistinct.ts +++ b/packages/drizzle/src/findDistinct.ts @@ -9,7 +9,6 @@ import { getTransaction } from './utilities/getTransaction.js' import { DistinctSymbol } from './utilities/rawConstraint.js' export const findDistinct: FindDistinct = async function (this: DrizzleAdapter, args) { - const db = await getTransaction(this, args.req) const collectionConfig: SanitizedCollectionConfig = this.payload.collections[args.collection].config const page = args.page || 1 @@ -36,6 +35,8 @@ export const findDistinct: FindDistinct = async function (this: DrizzleAdapter, orderBy.pop() + const db = await getTransaction(this, args.req) + const selectDistinctResult = await selectDistinct({ adapter: this, db, diff --git a/packages/drizzle/src/updateGlobal.ts b/packages/drizzle/src/updateGlobal.ts index e97bd15ea9c..959e99069d0 100644 --- a/packages/drizzle/src/updateGlobal.ts +++ b/packages/drizzle/src/updateGlobal.ts @@ -11,10 +11,10 @@ export async function updateGlobal>( this: DrizzleAdapter, { slug, data, req, returning, select }: UpdateGlobalArgs, ): Promise { - const db = await getTransaction(this, req) const globalConfig = this.payload.globals.config.find((config) => config.slug === slug) const tableName = this.tableNameMap.get(toSnakeCase(globalConfig.slug)) + const db = await getTransaction(this, req) const existingGlobal = await db.query[tableName].findFirst({}) const result = await upsertRow<{ globalType: string } & T>({ diff --git a/packages/drizzle/src/updateGlobalVersion.ts b/packages/drizzle/src/updateGlobalVersion.ts index 223e4096d14..1961a9822ac 100644 --- a/packages/drizzle/src/updateGlobalVersion.ts +++ b/packages/drizzle/src/updateGlobalVersion.ts @@ -27,7 +27,6 @@ export async function updateGlobalVersion( where: whereArg, }: UpdateGlobalVersionArgs, ): Promise> { - const db = await getTransaction(this, req) const globalConfig: SanitizedGlobalConfig = this.payload.globals.config.find( ({ slug }) => slug === global, ) @@ -47,6 +46,8 @@ export async function updateGlobalVersion( where: whereToUse, }) + const db = await getTransaction(this, req) + const result = await upsertRow>({ id, adapter: this, diff --git a/packages/drizzle/src/updateJobs.ts b/packages/drizzle/src/updateJobs.ts index 7463dab2480..8969cf7bd1e 100644 --- a/packages/drizzle/src/updateJobs.ts +++ b/packages/drizzle/src/updateJobs.ts @@ -23,7 +23,6 @@ export const updateJobs: UpdateJobs = async function updateMany( const whereToUse: Where = id ? { id: { equals: id } } : whereArg const limit = id ? 1 : limitArg - const db = await getTransaction(this, req) const collection = this.payload.collections['payload-jobs'].config const tableName = this.tableNameMap.get(toSnakeCase(collection.slug)) const sort = sortArg !== undefined && sortArg !== null ? sortArg : collection.defaultSort @@ -34,6 +33,8 @@ export const updateJobs: UpdateJobs = async function updateMany( }) if (useOptimizedUpsertRow && id) { + const db = await getTransaction(this, req) + const result = await upsertRow({ id, adapter: this, @@ -64,6 +65,8 @@ export const updateJobs: UpdateJobs = async function updateMany( return [] } + const db = await getTransaction(this, req) + const results = [] // TODO: We need to batch this to reduce the amount of db calls. This can get very slow if we are updating a lot of rows. diff --git a/packages/drizzle/src/updateMany.ts b/packages/drizzle/src/updateMany.ts index a0f8b773152..c4babe7f733 100644 --- a/packages/drizzle/src/updateMany.ts +++ b/packages/drizzle/src/updateMany.ts @@ -25,7 +25,6 @@ export const updateMany: UpdateMany = async function updateMany( where: whereToUse, }, ) { - const db = await getTransaction(this, req) const collection = this.payload.collections[collectionSlug].config const tableName = this.tableNameMap.get(toSnakeCase(collection.slug)) @@ -40,6 +39,8 @@ export const updateMany: UpdateMany = async function updateMany( where: whereToUse, }) + const db = await getTransaction(this, req) + let idsToUpdate: (number | string)[] = [] const selectDistinctResult = await selectDistinct({ diff --git a/packages/drizzle/src/updateOne.ts b/packages/drizzle/src/updateOne.ts index 8fddd9378fd..879d4344527 100644 --- a/packages/drizzle/src/updateOne.ts +++ b/packages/drizzle/src/updateOne.ts @@ -25,11 +25,12 @@ export const updateOne: UpdateOne = async function updateOne( where: whereArg, }, ) { - const db = await getTransaction(this, req) const collection = this.payload.collections[collectionSlug].config const tableName = this.tableNameMap.get(toSnakeCase(collection.slug)) let idToUpdate = id + const db = await getTransaction(this, req) + if (!idToUpdate) { const { joins, selectFields, where } = buildQuery({ adapter: this, diff --git a/packages/drizzle/src/updateVersion.ts b/packages/drizzle/src/updateVersion.ts index 49350b7bf8c..1572a141f13 100644 --- a/packages/drizzle/src/updateVersion.ts +++ b/packages/drizzle/src/updateVersion.ts @@ -27,7 +27,6 @@ export async function updateVersion( where: whereArg, }: UpdateVersionArgs, ): Promise> { - const db = await getTransaction(this, req) const collectionConfig: SanitizedCollectionConfig = this.payload.collections[collection].config const whereToUse = whereArg || { id: { equals: id } } const tableName = this.tableNameMap.get( @@ -44,6 +43,8 @@ export async function updateVersion( where: whereToUse, }) + const db = await getTransaction(this, req) + const result = await upsertRow>({ id, adapter: this, From 292aa60367b7cbf2503ad2613a1cb5d7e884da5f Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 17 Nov 2025 20:25:53 -0800 Subject: [PATCH 27/42] more transaction issue fixes --- packages/next/src/utilities/initReq.ts | 9 +++++- packages/next/src/views/Document/index.tsx | 31 ++++++++++++++++--- .../payload/src/utilities/createLocalReq.ts | 5 --- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/next/src/utilities/initReq.ts b/packages/next/src/utilities/initReq.ts index 6f6628d6771..3a800f3f1da 100644 --- a/packages/next/src/utilities/initReq.ts +++ b/packages/next/src/utilities/initReq.ts @@ -140,7 +140,14 @@ export const initReq = async function ({ // This ensures parallel operations using the same cache key don't affect each other. return { ...result, - req: { ...result.req }, + req: { + ...result.req, + ...(result.req?.context + ? { + context: { ...result.req.context }, + } + : {}), + }, } }) } diff --git a/packages/next/src/views/Document/index.tsx b/packages/next/src/views/Document/index.tsx index 20f333496c6..11f95067a28 100644 --- a/packages/next/src/views/Document/index.tsx +++ b/packages/next/src/views/Document/index.tsx @@ -21,7 +21,7 @@ import { handleLivePreview, handlePreview } from '@payloadcms/ui/rsc' import { isEditing as getIsEditing } from '@payloadcms/ui/shared' import { buildFormState } from '@payloadcms/ui/utilities/buildFormState' import { notFound, redirect } from 'next/navigation.js' -import { logError } from 'payload' +import { isolateObjectProperty, logError } from 'payload' import { formatAdminURL } from 'payload/shared' import React from 'react' @@ -140,6 +140,27 @@ export const renderDocument = async ({ const isTrashedDoc = Boolean(doc && 'deletedAt' in doc && typeof doc?.deletedAt === 'string') + // CRITICAL FIX FOR RACE CONDITION: + // When running parallel operations with Promise.all, if they share the same req object + // and one operation calls initTransaction() which MUTATES req.transactionID, that mutation + // is visible to all parallel operations. This causes: + // 1. Operation A (e.g., getDocumentPermissions → docAccessOperation) calls initTransaction() + // which sets req.transactionID = Promise, then resolves it to a UUID + // 2. Operation B (e.g., getIsLocked) running in parallel receives the SAME req with the mutated transactionID + // 3. Operation A (does not even know that Operation B even exists and is stil using the transactionID) commits/ends its transaction + // 4. Operation B tries to use the now-expired session → MongoExpiredSessionError! + // + // Solution: Use isolateObjectProperty to create a Proxy that isolates the 'transactionID' property. + // This allows each operation to have its own transactionID without affecting the parent req. + // If parent req already has a transaction, preserve it (don't isolate). + // + // Note: We use isolateObjectProperty instead of shallow copy ({ ...req }) because: + // - Shallow copy would break tests that expect req.transactionID mutations to be visible to the caller + // - isolateObjectProperty creates a Proxy where transactionID mutations go to a delegate object + // - The parent req remains unmutated while each child operation can have its own transaction + const reqForPermissions = req.transactionID ? req : isolateObjectProperty(req, 'transactionID') + const reqForLockCheck = req.transactionID ? req : isolateObjectProperty(req, 'transactionID') + const [ docPreferences, { docPermissions, hasPublishPermission, hasSavePermission }, @@ -155,22 +176,22 @@ export const renderDocument = async ({ user, }), - // Get permissions + // Get permissions - isolated transactionID prevents cross-contamination getDocumentPermissions({ id: idFromArgs, collectionConfig, data: doc, globalConfig, - req, + req: reqForPermissions, }), - // Fetch document lock state + // Fetch document lock state - isolated transactionID prevents cross-contamination getIsLocked({ id: idFromArgs, collectionConfig, globalConfig, isEditing, - req, + req: reqForLockCheck, }), // get entity preferences diff --git a/packages/payload/src/utilities/createLocalReq.ts b/packages/payload/src/utilities/createLocalReq.ts index 26d9af6a96b..0bfe979984e 100644 --- a/packages/payload/src/utilities/createLocalReq.ts +++ b/packages/payload/src/utilities/createLocalReq.ts @@ -99,11 +99,6 @@ export const createLocalReq: CreateLocalReq = async ( { context, fallbackLocale, locale: localeArg, req = {} as PayloadRequest, urlSuffix, user }, payload, ): Promise => { - // CRITICAL: Create a shallow copy of req to prevent mutations from propagating - // to the original req object (which may be cached or shared across operations) - // This preserves any intentional transactionID while preventing mutation leakage - req = { ...req } - const localization = payload.config?.localization if (localization) { From c8ba80593b1b7224824774edce035a92225a6927 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 17 Nov 2025 22:47:42 -0800 Subject: [PATCH 28/42] perf: full parallelism --- .../getEntityPermissions.ts | 2 +- .../populateFieldPermissions.ts | 178 ++++++++++-------- 2 files changed, 104 insertions(+), 76 deletions(-) diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts index 9be6d27f68f..b66de33b3a8 100644 --- a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -179,7 +179,7 @@ export async function getEntityPermissions => /** * Build up permissions object and run access functions for each field of an entity + * This function is synchronous and collects all async work into the promises array */ -export const populateFieldPermissions = async ({ +export const populateFieldPermissions = ({ id, blockReferencesPermissions, data, @@ -42,12 +43,9 @@ export const populateFieldPermissions = async ({ permissionsObject: FieldsPermissions promises: Promise[] req: PayloadRequest -}): Promise => { +}): void => { for (const field of fields) { - // Track pending access promises for this field across all operations - const fieldAccessPromises: Promise[] = [] - - // First pass: Set up permissions for all operations + // Set up permissions for all operations for (const operation of operations) { const parentPermissionForOperation = ( parentPermissionsObject[operation as keyof typeof parentPermissionsObject] as Permission @@ -75,41 +73,49 @@ export const populateFieldPermissions = async ({ // For consistency, it's thus better to never include the siblingData and blockData }) if (isThenable(accessResult)) { - const promise = accessResult.then((result) => { + // Store the promise and add to collection for later resolution + const permissionPromise = accessResult.then((result) => { fieldPermissions[operation] = { permission: Boolean(result), } }) - // If this field has nested content (fields or blocks), collect promises to await before processing nested content - // Otherwise, add to global promises array for parallel processing - if ( - ('fields' in field && field.fields) || - ('blocks' in field && field.blocks?.length) || - ('blockReferences' in field && field.blockReferences?.length) - ) { - fieldAccessPromises.push(promise) - } else { - promises.push(promise) + // Store promise directly so children can chain onto it + fieldPermissions[operation] = { + permission: permissionPromise as unknown as boolean, } + + promises.push(permissionPromise) } else { fieldPermissions[operation] = { permission: Boolean(accessResult), } } } else { - fieldPermissions[operation] = { - permission: parentPermissionForOperation, + // Parent permission might be a promise - if so, chain onto it + if (isThenable(parentPermissionForOperation)) { + const permissionPromise = (parentPermissionForOperation as Promise).then( + (result) => { + fieldPermissions[operation] = { + permission: result, + } + }, + ) + + fieldPermissions[operation] = { + permission: permissionPromise as unknown as boolean, + } + + promises.push(permissionPromise) + } else { + fieldPermissions[operation] = { + permission: parentPermissionForOperation, + } } } } } - // Await all field-level access promises before processing nested content - if (fieldAccessPromises.length > 0) { - await Promise.all(fieldAccessPromises) - } - // Handle named fields with nested content if ('name' in field && field.name) { const fieldPermissions: FieldPermissions = permissionsObject[field.name]! @@ -119,7 +125,7 @@ export const populateFieldPermissions = async ({ fieldPermissions.fields = {} } - await populateFieldPermissions({ + populateFieldPermissions({ id, blockReferencesPermissions, data, @@ -141,7 +147,7 @@ export const populateFieldPermissions = async ({ } const blocksPermissions: BlocksPermissions = fieldPermissions.blocks - // First, set up permissions for all operations for all blocks + // Set up permissions for all operations for all blocks for (const operation of operations) { // Fields don't have all operations of a collection if (operation === 'delete' || operation === 'readVersions' || operation === 'unlock') { @@ -164,13 +170,8 @@ export const populateFieldPermissions = async ({ if (typeof _block === 'string') { const blockReferencePermissions = blockReferencesPermissions[_block] if (blockReferencePermissions) { - if (isThenable(blockReferencePermissions)) { - // Earlier access to this block is still pending, so await it - blocksPermissions[block.slug] = await blockReferencePermissions - } else { - // It's already a resolved policy object - blocksPermissions[block.slug] = blockReferencePermissions - } + // Reference the cached permissions (may be a promise or resolved object) + blocksPermissions[block.slug] = blockReferencePermissions as BlockPermissions continue } } @@ -186,14 +187,30 @@ export const populateFieldPermissions = async ({ if (!blockPermission[operation]) { const fieldPermission = fieldPermissions[operation]?.permission ?? parentPermissionForOperation - blockPermission[operation] = { - permission: fieldPermission, + + // If parent permission is a promise, chain onto it + if (isThenable(fieldPermission)) { + const permissionPromise = (fieldPermission as Promise).then((result) => { + blockPermission[operation] = { + permission: result, + } + }) + + blockPermission[operation] = { + permission: permissionPromise as unknown as boolean, + } + + promises.push(permissionPromise) + } else { + blockPermission[operation] = { + permission: fieldPermission, + } } } } } - // Now process nested content for each unique block (once per block, not once per operation) + // Process nested content for each unique block (once per block, not once per operation) const processedBlocks = new Set() for (const _block of field.blockReferences ?? field.blocks) { const block = typeof _block === 'string' ? req.payload.blocks[_block] : _block @@ -204,34 +221,6 @@ export const populateFieldPermissions = async ({ } processedBlocks.add(block.slug) - // Handle block references with caching - if (typeof _block === 'string' && !blockReferencesPermissions[_block]) { - blockReferencesPermissions[_block] = (async (): Promise => { - const blockPermission = blocksPermissions[block.slug]! - if (!blockPermission.fields) { - blockPermission.fields = {} - } - - await populateFieldPermissions({ - id, - blockReferencesPermissions, - data, - fields: block.fields, - operations, - parentPermissionsObject: blockPermission, - permissionsObject: blockPermission.fields, - promises, - req, - }) - - return blockPermission - })() - - blocksPermissions[block.slug] = await blockReferencesPermissions[_block] - continue - } - - // Process inline blocks or already-resolved references const blockPermission = blocksPermissions[block.slug] if (!blockPermission) { continue @@ -241,7 +230,14 @@ export const populateFieldPermissions = async ({ blockPermission.fields = {} } - await populateFieldPermissions({ + // Handle block references with caching - store as promise that will be resolved later + if (typeof _block === 'string' && !blockReferencesPermissions[_block]) { + // Mark this block as being processed by storing a reference + blockReferencesPermissions[_block] = blockPermission + } + + // Recursively process block fields synchronously + populateFieldPermissions({ id, blockReferencesPermissions, data, @@ -258,8 +254,8 @@ export const populateFieldPermissions = async ({ // Handle unnamed group fields if ('fields' in field && field.fields && !('name' in field && field.name)) { - // Field does not have a name => same parentPermissionsObject => no need to await current level - await populateFieldPermissions({ + // Field does not have a name => same parentPermissionsObject + populateFieldPermissions({ id, blockReferencesPermissions, data, @@ -289,12 +285,44 @@ export const populateFieldPermissions = async ({ for (const tab of field.tabs) { if (tabHasName(tab)) { if (!permissionsObject[tab.name]) { - permissionsObject[tab.name] = { - fields: {}, - [operation]: { permission: parentPermissionForOperation }, - } as FieldPermissions + // Parent permission might be a promise - if so, set up chaining + if (isThenable(parentPermissionForOperation)) { + const tabPermissions: FieldPermissions = { + fields: {}, + [operation]: { permission: parentPermissionForOperation as unknown as boolean }, + } as FieldPermissions + + const permissionPromise = (parentPermissionForOperation as Promise).then( + (result) => { + tabPermissions[operation] = { permission: result } + }, + ) + + promises.push(permissionPromise) + permissionsObject[tab.name] = tabPermissions + } else { + permissionsObject[tab.name] = { + fields: {}, + [operation]: { permission: parentPermissionForOperation }, + } as FieldPermissions + } } else if (!permissionsObject[tab.name]![operation]) { - permissionsObject[tab.name]![operation] = { permission: parentPermissionForOperation } + // Parent permission might be a promise - if so, set up chaining + if (isThenable(parentPermissionForOperation)) { + const tabPermissions = permissionsObject[tab.name]! + const permissionPromise = (parentPermissionForOperation as Promise).then( + (result) => { + tabPermissions[operation] = { permission: result } + }, + ) + + tabPermissions[operation] = { permission: permissionPromise as unknown as boolean } + promises.push(permissionPromise) + } else { + permissionsObject[tab.name]![operation] = { + permission: parentPermissionForOperation, + } + } } } } @@ -308,7 +336,7 @@ export const populateFieldPermissions = async ({ tabPermissions.fields = {} } - await populateFieldPermissions({ + populateFieldPermissions({ id, blockReferencesPermissions, data, @@ -320,8 +348,8 @@ export const populateFieldPermissions = async ({ req, }) } else { - // Tab does not have a name => same parentPermissionsObject => no need to await current level - await populateFieldPermissions({ + // Tab does not have a name => same parentPermissionsObject + populateFieldPermissions({ id, blockReferencesPermissions, data, From 91f00395e2c83a2e5ce0b028eb4e48d98eb56d89 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 17 Nov 2025 23:10:18 -0800 Subject: [PATCH 29/42] simplify a lot --- .../populateFieldPermissions.ts | 134 ++++++------------ 1 file changed, 42 insertions(+), 92 deletions(-) diff --git a/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts index 1cfa0722e93..6668d7ae2a6 100644 --- a/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts @@ -16,6 +16,32 @@ import { type Field, tabHasName } from '../../fields/config/types.js' const isThenable = (value: unknown): value is Promise => value != null && typeof (value as { then?: unknown }).then === 'function' +/** + * Helper to set a permission value that might be a promise. + * If it's a promise, creates a chained promise that resolves to update the target, + * stores the promise temporarily, and adds it to the promises array for later resolution. + */ +const setPermission = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + target: any, + operation: AllOperations, + value: boolean | Promise | undefined, + promises: Promise[], +): void => { + if (isThenable(value)) { + const permissionPromise = value.then((result) => { + target[operation] = { permission: result } + }) + + // Store promise temporarily, so that children can access the permission before it is resolved. + // It will be overwritten when promise resolves + target[operation] = { permission: permissionPromise as unknown as boolean } + promises.push(permissionPromise) + } else { + target[operation] = { permission: value } + } +} + /** * Build up permissions object and run access functions for each field of an entity * This function is synchronous and collects all async work into the promises array @@ -72,46 +98,17 @@ export const populateFieldPermissions = ({ // rows, as we're calculating schema permissions, which do not include individual rows. // For consistency, it's thus better to never include the siblingData and blockData }) - if (isThenable(accessResult)) { - // Store the promise and add to collection for later resolution - const permissionPromise = accessResult.then((result) => { - fieldPermissions[operation] = { - permission: Boolean(result), - } - }) - - // Store promise directly so children can chain onto it - fieldPermissions[operation] = { - permission: permissionPromise as unknown as boolean, - } - promises.push(permissionPromise) + // Handle both sync and async access results + if (isThenable(accessResult)) { + const booleanPromise = accessResult.then((result) => Boolean(result)) + setPermission(fieldPermissions, operation, booleanPromise, promises) } else { - fieldPermissions[operation] = { - permission: Boolean(accessResult), - } + setPermission(fieldPermissions, operation, Boolean(accessResult), promises) } } else { - // Parent permission might be a promise - if so, chain onto it - if (isThenable(parentPermissionForOperation)) { - const permissionPromise = (parentPermissionForOperation as Promise).then( - (result) => { - fieldPermissions[operation] = { - permission: result, - } - }, - ) - - fieldPermissions[operation] = { - permission: permissionPromise as unknown as boolean, - } - - promises.push(permissionPromise) - } else { - fieldPermissions[operation] = { - permission: parentPermissionForOperation, - } - } + // Inherit from parent (which might be a promise) + setPermission(fieldPermissions, operation, parentPermissionForOperation, promises) } } } @@ -188,24 +185,8 @@ export const populateFieldPermissions = ({ const fieldPermission = fieldPermissions[operation]?.permission ?? parentPermissionForOperation - // If parent permission is a promise, chain onto it - if (isThenable(fieldPermission)) { - const permissionPromise = (fieldPermission as Promise).then((result) => { - blockPermission[operation] = { - permission: result, - } - }) - - blockPermission[operation] = { - permission: permissionPromise as unknown as boolean, - } - - promises.push(permissionPromise) - } else { - blockPermission[operation] = { - permission: fieldPermission, - } - } + // Inherit from field permission (which might be a promise) + setPermission(blockPermission, operation, fieldPermission, promises) } } } @@ -285,44 +266,13 @@ export const populateFieldPermissions = ({ for (const tab of field.tabs) { if (tabHasName(tab)) { if (!permissionsObject[tab.name]) { - // Parent permission might be a promise - if so, set up chaining - if (isThenable(parentPermissionForOperation)) { - const tabPermissions: FieldPermissions = { - fields: {}, - [operation]: { permission: parentPermissionForOperation as unknown as boolean }, - } as FieldPermissions - - const permissionPromise = (parentPermissionForOperation as Promise).then( - (result) => { - tabPermissions[operation] = { permission: result } - }, - ) - - promises.push(permissionPromise) - permissionsObject[tab.name] = tabPermissions - } else { - permissionsObject[tab.name] = { - fields: {}, - [operation]: { permission: parentPermissionForOperation }, - } as FieldPermissions - } - } else if (!permissionsObject[tab.name]![operation]) { - // Parent permission might be a promise - if so, set up chaining - if (isThenable(parentPermissionForOperation)) { - const tabPermissions = permissionsObject[tab.name]! - const permissionPromise = (parentPermissionForOperation as Promise).then( - (result) => { - tabPermissions[operation] = { permission: result } - }, - ) - - tabPermissions[operation] = { permission: permissionPromise as unknown as boolean } - promises.push(permissionPromise) - } else { - permissionsObject[tab.name]![operation] = { - permission: parentPermissionForOperation, - } - } + permissionsObject[tab.name] = { fields: {} } as FieldPermissions + } + + const tabPermissions = permissionsObject[tab.name]! + if (!tabPermissions[operation]) { + // Inherit from parent (which might be a promise) + setPermission(tabPermissions, operation, parentPermissionForOperation, promises) } } } From bef53672b1917d70c87b20269bede97cee4e1123 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Tue, 18 Nov 2025 11:05:08 -0800 Subject: [PATCH 30/42] Revert "Merge remote-tracking branch 'origin/fix/transaction-issues' into fix/update-field-access-control-after-save" This reverts commit 4b612bc871a1eac19a474009b6193a7a01fe04c7, reversing changes made to 28d7bc71893ffb7960caf7284ac4d8ef91202ae8. --- packages/db-mongodb/src/count.ts | 8 ++--- .../db-mongodb/src/countGlobalVersions.ts | 8 ++--- packages/db-mongodb/src/countVersions.ts | 8 ++--- packages/db-mongodb/src/create.ts | 12 +++---- .../db-mongodb/src/createGlobalVersion.ts | 12 +++---- packages/db-mongodb/src/createVersion.ts | 12 +++---- packages/db-mongodb/src/deleteMany.ts | 8 ++--- packages/db-mongodb/src/deleteOne.ts | 14 ++++----- packages/db-mongodb/src/deleteVersions.ts | 4 +-- packages/db-mongodb/src/find.ts | 4 +-- packages/db-mongodb/src/findDistinct.ts | 4 +-- packages/db-mongodb/src/findGlobal.ts | 17 +++++----- packages/db-mongodb/src/findGlobalVersions.ts | 14 ++++----- packages/db-mongodb/src/findOne.ts | 12 +++---- packages/db-mongodb/src/findVersions.ts | 14 ++++----- packages/db-mongodb/src/queryDrafts.ts | 10 +++--- packages/db-mongodb/src/updateGlobal.ts | 4 +-- .../db-mongodb/src/updateGlobalVersion.ts | 19 ++++++------ packages/db-mongodb/src/updateJobs.ts | 16 +++++----- packages/db-mongodb/src/updateMany.ts | 28 ++++++++--------- packages/db-mongodb/src/updateOne.ts | 28 ++++++++--------- packages/db-mongodb/src/updateVersion.ts | 18 +++++------ packages/drizzle/src/count.ts | 4 +-- packages/drizzle/src/countGlobalVersions.ts | 4 +-- packages/drizzle/src/countVersions.ts | 4 +-- packages/drizzle/src/create.ts | 3 +- packages/drizzle/src/createGlobal.ts | 3 +- packages/drizzle/src/createGlobalVersion.ts | 3 +- packages/drizzle/src/createVersion.ts | 3 +- packages/drizzle/src/deleteMany.ts | 3 +- packages/drizzle/src/deleteOne.ts | 3 +- packages/drizzle/src/deleteVersions.ts | 4 +-- packages/drizzle/src/find/findMany.ts | 3 +- packages/drizzle/src/findDistinct.ts | 3 +- packages/drizzle/src/updateGlobal.ts | 2 +- packages/drizzle/src/updateGlobalVersion.ts | 3 +- packages/drizzle/src/updateJobs.ts | 5 +-- packages/drizzle/src/updateMany.ts | 3 +- packages/drizzle/src/updateOne.ts | 3 +- packages/drizzle/src/updateVersion.ts | 3 +- packages/next/src/utilities/initReq.ts | 9 +----- packages/next/src/views/Document/index.tsx | 31 +++---------------- .../payload/src/utilities/createLocalReq.ts | 5 +++ 43 files changed, 168 insertions(+), 210 deletions(-) diff --git a/packages/db-mongodb/src/count.ts b/packages/db-mongodb/src/count.ts index e88fb5e59c6..fd0aa284d20 100644 --- a/packages/db-mongodb/src/count.ts +++ b/packages/db-mongodb/src/count.ts @@ -15,6 +15,10 @@ export const count: Count = async function count( ) { const { collectionConfig, Model } = getCollection({ adapter: this, collectionSlug }) + const options: CountOptions = { + session: await getSession(this, req), + } + let hasNearConstraint = false if (where) { @@ -33,10 +37,6 @@ export const count: Count = async function count( // useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters. const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0 - const options: CountOptions = { - session: await getSession(this, req), - } - if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) { // Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding // a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents, diff --git a/packages/db-mongodb/src/countGlobalVersions.ts b/packages/db-mongodb/src/countGlobalVersions.ts index 0d4ff89a298..c91f1c8fd85 100644 --- a/packages/db-mongodb/src/countGlobalVersions.ts +++ b/packages/db-mongodb/src/countGlobalVersions.ts @@ -15,6 +15,10 @@ export const countGlobalVersions: CountGlobalVersions = async function countGlob ) { const { globalConfig, Model } = getGlobal({ adapter: this, globalSlug, versions: true }) + const options: CountOptions = { + session: await getSession(this, req), + } + let hasNearConstraint = false if (where) { @@ -32,10 +36,6 @@ export const countGlobalVersions: CountGlobalVersions = async function countGlob // useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters. const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0 - const options: CountOptions = { - session: await getSession(this, req), - } - if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) { // Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding // a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents, diff --git a/packages/db-mongodb/src/countVersions.ts b/packages/db-mongodb/src/countVersions.ts index d3ba5690160..bb31931cb00 100644 --- a/packages/db-mongodb/src/countVersions.ts +++ b/packages/db-mongodb/src/countVersions.ts @@ -19,6 +19,10 @@ export const countVersions: CountVersions = async function countVersions( versions: true, }) + const options: CountOptions = { + session: await getSession(this, req), + } + let hasNearConstraint = false if (where) { @@ -36,10 +40,6 @@ export const countVersions: CountVersions = async function countVersions( // useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters. const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0 - const options: CountOptions = { - session: await getSession(this, req), - } - if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) { // Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding // a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents, diff --git a/packages/db-mongodb/src/create.ts b/packages/db-mongodb/src/create.ts index 9c96cd32467..82e65fcd702 100644 --- a/packages/db-mongodb/src/create.ts +++ b/packages/db-mongodb/src/create.ts @@ -15,6 +15,12 @@ export const create: Create = async function create( ) { const { collectionConfig, customIDType, Model } = getCollection({ adapter: this, collectionSlug }) + const options: CreateOptions = { + session: await getSession(this, req), + // Timestamps are manually added by the write transform + timestamps: false, + } + let doc if (!data.createdAt) { @@ -41,12 +47,6 @@ export const create: Create = async function create( } } - const options: CreateOptions = { - session: await getSession(this, req), - // Timestamps are manually added by the write transform - timestamps: false, - } - try { ;[doc] = await Model.create([data], options) } catch (error) { diff --git a/packages/db-mongodb/src/createGlobalVersion.ts b/packages/db-mongodb/src/createGlobalVersion.ts index 64104d6421d..0469d919e39 100644 --- a/packages/db-mongodb/src/createGlobalVersion.ts +++ b/packages/db-mongodb/src/createGlobalVersion.ts @@ -22,6 +22,12 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo ) { const { globalConfig, Model } = getGlobal({ adapter: this, globalSlug, versions: true }) + const options = { + session: await getSession(this, req), + // Timestamps are manually added by the write transform + timestamps: false, + } + const data = { autosave, createdAt, @@ -44,12 +50,6 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo operation: 'write', }) - const options = { - session: await getSession(this, req), - // Timestamps are manually added by the write transform - timestamps: false, - } - let [doc] = await Model.create([data], options, req) await Model.updateMany( diff --git a/packages/db-mongodb/src/createVersion.ts b/packages/db-mongodb/src/createVersion.ts index aba93c496ad..a6a7b5c2c20 100644 --- a/packages/db-mongodb/src/createVersion.ts +++ b/packages/db-mongodb/src/createVersion.ts @@ -27,6 +27,12 @@ export const createVersion: CreateVersion = async function createVersion( versions: true, }) + const options = { + session: await getSession(this, req), + // Timestamps are manually added by the write transform + timestamps: false, + } + const data = { autosave, createdAt, @@ -50,12 +56,6 @@ export const createVersion: CreateVersion = async function createVersion( operation: 'write', }) - const options = { - session: await getSession(this, req), - // Timestamps are manually added by the write transform - timestamps: false, - } - let [doc] = await Model.create([data], options, req) const parentQuery = { diff --git a/packages/db-mongodb/src/deleteMany.ts b/packages/db-mongodb/src/deleteMany.ts index ebaee1df3f5..a40fc7fb610 100644 --- a/packages/db-mongodb/src/deleteMany.ts +++ b/packages/db-mongodb/src/deleteMany.ts @@ -14,6 +14,10 @@ export const deleteMany: DeleteMany = async function deleteMany( ) { const { collectionConfig, Model } = getCollection({ adapter: this, collectionSlug }) + const options: DeleteOptions = { + session: await getSession(this, req), + } + const query = await buildQuery({ adapter: this, collectionSlug, @@ -21,9 +25,5 @@ export const deleteMany: DeleteMany = async function deleteMany( where, }) - const options: DeleteOptions = { - session: await getSession(this, req), - } - await Model.deleteMany(query, options) } diff --git a/packages/db-mongodb/src/deleteOne.ts b/packages/db-mongodb/src/deleteOne.ts index 713b8d9fea0..1731642780f 100644 --- a/packages/db-mongodb/src/deleteOne.ts +++ b/packages/db-mongodb/src/deleteOne.ts @@ -15,13 +15,6 @@ export const deleteOne: DeleteOne = async function deleteOne( ) { const { collectionConfig, Model } = getCollection({ adapter: this, collectionSlug }) - const query = await buildQuery({ - adapter: this, - collectionSlug, - fields: collectionConfig.flattenedFields, - where, - }) - const options: MongooseUpdateQueryOptions = { projection: buildProjectionFromSelect({ adapter: this, @@ -31,6 +24,13 @@ export const deleteOne: DeleteOne = async function deleteOne( session: await getSession(this, req), } + const query = await buildQuery({ + adapter: this, + collectionSlug, + fields: collectionConfig.flattenedFields, + where, + }) + if (returning === false) { await Model.deleteOne(query, options)?.lean() return null diff --git a/packages/db-mongodb/src/deleteVersions.ts b/packages/db-mongodb/src/deleteVersions.ts index 97436494d78..1d311b4f34c 100644 --- a/packages/db-mongodb/src/deleteVersions.ts +++ b/packages/db-mongodb/src/deleteVersions.ts @@ -36,6 +36,8 @@ export const deleteVersions: DeleteVersions = async function deleteVersions( throw new APIError('Either collection or globalSlug must be passed.') } + const session = await getSession(this, req) + const query = await buildQuery({ adapter: this, fields, @@ -43,7 +45,5 @@ export const deleteVersions: DeleteVersions = async function deleteVersions( where, }) - const session = await getSession(this, req) - await VersionsModel.deleteMany(query, { session }) } diff --git a/packages/db-mongodb/src/find.ts b/packages/db-mongodb/src/find.ts index c3e655f9f4f..907bd744844 100644 --- a/packages/db-mongodb/src/find.ts +++ b/packages/db-mongodb/src/find.ts @@ -34,6 +34,8 @@ export const find: Find = async function find( ) { const { collectionConfig, Model } = getCollection({ adapter: this, collectionSlug }) + const session = await getSession(this, req) + let hasNearConstraint = false if (where) { @@ -64,8 +66,6 @@ export const find: Find = async function find( where, }) - const session = await getSession(this, req) - // useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters. const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0 const paginationOptions: PaginateOptions = { diff --git a/packages/db-mongodb/src/findDistinct.ts b/packages/db-mongodb/src/findDistinct.ts index 66943f06525..f72977eb2a6 100644 --- a/packages/db-mongodb/src/findDistinct.ts +++ b/packages/db-mongodb/src/findDistinct.ts @@ -16,6 +16,8 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter, collectionSlug: args.collection, }) + const session = await getSession(this, args.req) + const { where = {} } = args let sortAggregation: PipelineStage[] = [] @@ -200,8 +202,6 @@ export const findDistinct: FindDistinct = async function (this: MongooseAdapter, }, ] - const session = await getSession(this, args.req) - const getValues = async () => { return Model.aggregate(pipeline, { session }).then((res) => res.map((each) => ({ diff --git a/packages/db-mongodb/src/findGlobal.ts b/packages/db-mongodb/src/findGlobal.ts index c87fea72da9..18e3ad5eb68 100644 --- a/packages/db-mongodb/src/findGlobal.ts +++ b/packages/db-mongodb/src/findGlobal.ts @@ -18,15 +18,6 @@ export const findGlobal: FindGlobal = async function findGlobal( const { globalConfig, Model } = getGlobal({ adapter: this, globalSlug }) const fields = globalConfig.flattenedFields - - const query = await buildQuery({ - adapter: this, - fields, - globalSlug, - locale, - where: combineQueries({ globalType: { equals: globalSlug } }, where), - }) - const options: QueryOptions = { lean: true, select: buildProjectionFromSelect({ @@ -37,6 +28,14 @@ export const findGlobal: FindGlobal = async function findGlobal( session: await getSession(this, req), } + const query = await buildQuery({ + adapter: this, + fields, + globalSlug, + locale, + where: combineQueries({ globalType: { equals: globalSlug } }, where), + }) + const doc: any = await Model.findOne(query, {}, options) if (!doc) { diff --git a/packages/db-mongodb/src/findGlobalVersions.ts b/packages/db-mongodb/src/findGlobalVersions.ts index f85aafa3567..76e9bc5fbc9 100644 --- a/packages/db-mongodb/src/findGlobalVersions.ts +++ b/packages/db-mongodb/src/findGlobalVersions.ts @@ -31,6 +31,13 @@ export const findGlobalVersions: FindGlobalVersions = async function findGlobalV const versionFields = buildVersionGlobalFields(this.payload.config, globalConfig, true) + const session = await getSession(this, req) + const options: QueryOptions = { + limit, + session, + skip, + } + let hasNearConstraint = false if (where) { @@ -57,13 +64,6 @@ export const findGlobalVersions: FindGlobalVersions = async function findGlobalV where, }) - const session = await getSession(this, req) - const options: QueryOptions = { - limit, - session, - skip, - } - // useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters. const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0 const paginationOptions: PaginateOptions = { diff --git a/packages/db-mongodb/src/findOne.ts b/packages/db-mongodb/src/findOne.ts index 6abf81bdcbc..cf6edb34f05 100644 --- a/packages/db-mongodb/src/findOne.ts +++ b/packages/db-mongodb/src/findOne.ts @@ -19,6 +19,12 @@ export const findOne: FindOne = async function findOne( ) { const { collectionConfig, Model } = getCollection({ adapter: this, collectionSlug }) + const session = await getSession(this, req) + const options: AggregateOptions & QueryOptions = { + lean: true, + session, + } + const query = await buildQuery({ adapter: this, collectionSlug, @@ -44,12 +50,6 @@ export const findOne: FindOne = async function findOne( query, }) - const session = await getSession(this, req) - const options: AggregateOptions & QueryOptions = { - lean: true, - session, - } - let doc if (aggregate) { const { docs } = await aggregatePaginate({ diff --git a/packages/db-mongodb/src/findVersions.ts b/packages/db-mongodb/src/findVersions.ts index 4b30753406b..85be3661106 100644 --- a/packages/db-mongodb/src/findVersions.ts +++ b/packages/db-mongodb/src/findVersions.ts @@ -33,6 +33,13 @@ export const findVersions: FindVersions = async function findVersions( versions: true, }) + const session = await getSession(this, req) + const options: QueryOptions = { + limit, + session, + skip, + } + let hasNearConstraint = false if (where) { @@ -61,13 +68,6 @@ export const findVersions: FindVersions = async function findVersions( where, }) - const session = await getSession(this, req) - const options: QueryOptions = { - limit, - session, - skip, - } - // useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters. const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0 const paginationOptions: PaginateOptions = { diff --git a/packages/db-mongodb/src/queryDrafts.ts b/packages/db-mongodb/src/queryDrafts.ts index 7af5faaa78a..1dd0e84dafd 100644 --- a/packages/db-mongodb/src/queryDrafts.ts +++ b/packages/db-mongodb/src/queryDrafts.ts @@ -36,6 +36,10 @@ export const queryDrafts: QueryDrafts = async function queryDrafts( versions: true, }) + const options: QueryOptions = { + session: await getSession(this, req), + } + let hasNearConstraint let sort @@ -74,12 +78,6 @@ export const queryDrafts: QueryDrafts = async function queryDrafts( fields, select, }) - - const session = await getSession(this, req) - const options: QueryOptions = { - session, - } - // useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters. const useEstimatedCount = hasNearConstraint || !versionQuery || Object.keys(versionQuery).length === 0 diff --git a/packages/db-mongodb/src/updateGlobal.ts b/packages/db-mongodb/src/updateGlobal.ts index 8cdb644ac3d..2ed7aa17069 100644 --- a/packages/db-mongodb/src/updateGlobal.ts +++ b/packages/db-mongodb/src/updateGlobal.ts @@ -16,8 +16,6 @@ export const updateGlobal: UpdateGlobal = async function updateGlobal( const fields = globalConfig.fields - transform({ adapter: this, data, fields, globalSlug, operation: 'write' }) - const options: MongooseUpdateQueryOptions = { ...optionsArgs, lean: true, @@ -32,6 +30,8 @@ export const updateGlobal: UpdateGlobal = async function updateGlobal( timestamps: false, } + transform({ adapter: this, data, fields, globalSlug, operation: 'write' }) + if (returning === false) { await Model.updateOne({ globalType: globalSlug }, data, options) return null diff --git a/packages/db-mongodb/src/updateGlobalVersion.ts b/packages/db-mongodb/src/updateGlobalVersion.ts index c924e4803bd..0ec825b5a57 100644 --- a/packages/db-mongodb/src/updateGlobalVersion.ts +++ b/packages/db-mongodb/src/updateGlobalVersion.ts @@ -30,16 +30,6 @@ export async function updateGlobalVersion( const fields = buildVersionGlobalFields(this.payload.config, globalConfig) const flattenedFields = buildVersionGlobalFields(this.payload.config, globalConfig, true) - - const query = await buildQuery({ - adapter: this, - fields: flattenedFields, - locale, - where: whereToUse, - }) - - transform({ adapter: this, data: versionData, fields, operation: 'write' }) - const options: MongooseUpdateQueryOptions = { ...optionsArgs, lean: true, @@ -54,6 +44,15 @@ export async function updateGlobalVersion( timestamps: false, } + const query = await buildQuery({ + adapter: this, + fields: flattenedFields, + locale, + where: whereToUse, + }) + + transform({ adapter: this, data: versionData, fields, operation: 'write' }) + if (returning === false) { await Model.updateOne(query, versionData, options) return null diff --git a/packages/db-mongodb/src/updateJobs.ts b/packages/db-mongodb/src/updateJobs.ts index 5de930263ff..956d32b4699 100644 --- a/packages/db-mongodb/src/updateJobs.ts +++ b/packages/db-mongodb/src/updateJobs.ts @@ -36,6 +36,14 @@ export const updateJobs: UpdateJobs = async function updateMany( timestamps: true, }) + const options: MongooseUpdateQueryOptions = { + lean: true, + new: true, + session: await getSession(this, req), + // Timestamps are manually added by the write transform + timestamps: false, + } + let query = await buildQuery({ adapter: this, collectionSlug: collectionConfig.slug, @@ -80,14 +88,6 @@ export const updateJobs: UpdateJobs = async function updateMany( updateData = updateOps } - const options: MongooseUpdateQueryOptions = { - lean: true, - new: true, - session: await getSession(this, req), - // Timestamps are manually added by the write transform - timestamps: false, - } - let result: Job[] = [] try { diff --git a/packages/db-mongodb/src/updateMany.ts b/packages/db-mongodb/src/updateMany.ts index 192322a89ac..850a99c4c60 100644 --- a/packages/db-mongodb/src/updateMany.ts +++ b/packages/db-mongodb/src/updateMany.ts @@ -48,6 +48,20 @@ export const updateMany: UpdateMany = async function updateMany( }) } + const options: MongooseUpdateQueryOptions = { + ...optionsArgs, + lean: true, + new: true, + projection: buildProjectionFromSelect({ + adapter: this, + fields: collectionConfig.flattenedFields, + select, + }), + session: await getSession(this, req), + // Timestamps are manually added by the write transform + timestamps: false, + } + let query = await buildQuery({ adapter: this, collectionSlug, @@ -91,20 +105,6 @@ export const updateMany: UpdateMany = async function updateMany( data = updateOps } - const options: MongooseUpdateQueryOptions = { - ...optionsArgs, - lean: true, - new: true, - projection: buildProjectionFromSelect({ - adapter: this, - fields: collectionConfig.flattenedFields, - select, - }), - session: await getSession(this, req), - // Timestamps are manually added by the write transform - timestamps: false, - } - try { if (typeof limit === 'number' && limit > 0) { const documentsToUpdate = await Model.find( diff --git a/packages/db-mongodb/src/updateOne.ts b/packages/db-mongodb/src/updateOne.ts index 53c2ab0ee63..938a6eac53f 100644 --- a/packages/db-mongodb/src/updateOne.ts +++ b/packages/db-mongodb/src/updateOne.ts @@ -28,6 +28,20 @@ export const updateOne: UpdateOne = async function updateOne( const where = id ? { id: { equals: id } } : whereArg const fields = collectionConfig.fields + const options: MongooseUpdateQueryOptions = { + ...optionsArgs, + lean: true, + new: true, + projection: buildProjectionFromSelect({ + adapter: this, + fields: collectionConfig.flattenedFields, + select, + }), + session: await getSession(this, req), + // Timestamps are manually added by the write transform + timestamps: false, + } + const query = await buildQuery({ adapter: this, collectionSlug, @@ -75,20 +89,6 @@ export const updateOne: UpdateOne = async function updateOne( updateData = updateOps } - const options: MongooseUpdateQueryOptions = { - ...optionsArgs, - lean: true, - new: true, - projection: buildProjectionFromSelect({ - adapter: this, - fields: collectionConfig.flattenedFields, - select, - }), - session: await getSession(this, req), - // Timestamps are manually added by the write transform - timestamps: false, - } - try { if (returning === false) { await Model.updateOne(query, updateData, options) diff --git a/packages/db-mongodb/src/updateVersion.ts b/packages/db-mongodb/src/updateVersion.ts index 94241118604..4b2cbc5473f 100644 --- a/packages/db-mongodb/src/updateVersion.ts +++ b/packages/db-mongodb/src/updateVersion.ts @@ -35,15 +35,6 @@ export const updateVersion: UpdateVersion = async function updateVersion( const flattenedFields = buildVersionCollectionFields(this.payload.config, collectionConfig, true) - const query = await buildQuery({ - adapter: this, - fields: flattenedFields, - locale, - where: whereToUse, - }) - - transform({ adapter: this, data: versionData, fields, operation: 'write' }) - const options: MongooseUpdateQueryOptions = { ...optionsArgs, lean: true, @@ -58,6 +49,15 @@ export const updateVersion: UpdateVersion = async function updateVersion( timestamps: false, } + const query = await buildQuery({ + adapter: this, + fields: flattenedFields, + locale, + where: whereToUse, + }) + + transform({ adapter: this, data: versionData, fields, operation: 'write' }) + if (returning === false) { await Model.updateOne(query, versionData, options) return null diff --git a/packages/drizzle/src/count.ts b/packages/drizzle/src/count.ts index b5ee8d15d86..b7e415e27a1 100644 --- a/packages/drizzle/src/count.ts +++ b/packages/drizzle/src/count.ts @@ -15,6 +15,8 @@ export const count: Count = async function count( const tableName = this.tableNameMap.get(toSnakeCase(collectionConfig.slug)) + const db = await getTransaction(this, req) + const { joins, where } = buildQuery({ adapter: this, fields: collectionConfig.flattenedFields, @@ -23,8 +25,6 @@ export const count: Count = async function count( where: whereArg, }) - const db = await getTransaction(this, req) - const countResult = await this.countDistinct({ db, joins, diff --git a/packages/drizzle/src/countGlobalVersions.ts b/packages/drizzle/src/countGlobalVersions.ts index 0552d65fe3b..4e9e7a86bb8 100644 --- a/packages/drizzle/src/countGlobalVersions.ts +++ b/packages/drizzle/src/countGlobalVersions.ts @@ -20,6 +20,8 @@ export const countGlobalVersions: CountGlobalVersions = async function countGlob `_${toSnakeCase(globalConfig.slug)}${this.versionsSuffix}`, ) + const db = await getTransaction(this, req) + const fields = buildVersionGlobalFields(this.payload.config, globalConfig, true) const { joins, where } = buildQuery({ @@ -30,8 +32,6 @@ export const countGlobalVersions: CountGlobalVersions = async function countGlob where: whereArg, }) - const db = await getTransaction(this, req) - const countResult = await this.countDistinct({ db, joins, diff --git a/packages/drizzle/src/countVersions.ts b/packages/drizzle/src/countVersions.ts index 272c1fd6999..5ba2e2fc119 100644 --- a/packages/drizzle/src/countVersions.ts +++ b/packages/drizzle/src/countVersions.ts @@ -18,6 +18,8 @@ export const countVersions: CountVersions = async function countVersions( `_${toSnakeCase(collectionConfig.slug)}${this.versionsSuffix}`, ) + const db = await getTransaction(this, req) + const fields = buildVersionCollectionFields(this.payload.config, collectionConfig, true) const { joins, where } = buildQuery({ @@ -28,8 +30,6 @@ export const countVersions: CountVersions = async function countVersions( where: whereArg, }) - const db = await getTransaction(this, req) - const countResult = await this.countDistinct({ db, joins, diff --git a/packages/drizzle/src/create.ts b/packages/drizzle/src/create.ts index 4ae8997e6c2..3901cf8a10a 100644 --- a/packages/drizzle/src/create.ts +++ b/packages/drizzle/src/create.ts @@ -11,12 +11,11 @@ export const create: Create = async function create( this: DrizzleAdapter, { collection: collectionSlug, data, req, returning, select }, ) { + const db = await getTransaction(this, req) const collection = this.payload.collections[collectionSlug].config const tableName = this.tableNameMap.get(toSnakeCase(collection.slug)) - const db = await getTransaction(this, req) - const result = await upsertRow({ adapter: this, data, diff --git a/packages/drizzle/src/createGlobal.ts b/packages/drizzle/src/createGlobal.ts index d04188b3d53..8d1fd0c2d69 100644 --- a/packages/drizzle/src/createGlobal.ts +++ b/packages/drizzle/src/createGlobal.ts @@ -11,14 +11,13 @@ export async function createGlobal>( this: DrizzleAdapter, { slug, data, req, returning }: CreateGlobalArgs, ): Promise { + const db = await getTransaction(this, req) const globalConfig = this.payload.globals.config.find((config) => config.slug === slug) const tableName = this.tableNameMap.get(toSnakeCase(globalConfig.slug)) data.createdAt = new Date().toISOString() - const db = await getTransaction(this, req) - const result = await upsertRow<{ globalType: string } & T>({ adapter: this, data, diff --git a/packages/drizzle/src/createGlobalVersion.ts b/packages/drizzle/src/createGlobalVersion.ts index 8b7371d9ff6..b9382acc44d 100644 --- a/packages/drizzle/src/createGlobalVersion.ts +++ b/packages/drizzle/src/createGlobalVersion.ts @@ -24,12 +24,11 @@ export async function createGlobalVersion( versionData, }: CreateGlobalVersionArgs, ): Promise> { + const db = await getTransaction(this, req) const global = this.payload.globals.config.find(({ slug }) => slug === globalSlug) const tableName = this.tableNameMap.get(`_${toSnakeCase(global.slug)}${this.versionsSuffix}`) - const db = await getTransaction(this, req) - const result = await upsertRow>({ adapter: this, data: { diff --git a/packages/drizzle/src/createVersion.ts b/packages/drizzle/src/createVersion.ts index a7a1dc7eeab..a76152c0273 100644 --- a/packages/drizzle/src/createVersion.ts +++ b/packages/drizzle/src/createVersion.ts @@ -25,6 +25,7 @@ export async function createVersion( versionData, }: CreateVersionArgs, ): Promise> { + const db = await getTransaction(this, req) const collection = this.payload.collections[collectionSlug].config const defaultTableName = toSnakeCase(collection.slug) @@ -46,8 +47,6 @@ export async function createVersion( version, } - const db = await getTransaction(this, req) - const result = await upsertRow>({ adapter: this, data, diff --git a/packages/drizzle/src/deleteMany.ts b/packages/drizzle/src/deleteMany.ts index 7e191abc41b..9e10290a30b 100644 --- a/packages/drizzle/src/deleteMany.ts +++ b/packages/drizzle/src/deleteMany.ts @@ -13,6 +13,7 @@ export const deleteMany: DeleteMany = async function deleteMany( this: DrizzleAdapter, { collection, req, where: whereArg }, ) { + const db = await getTransaction(this, req) const collectionConfig = this.payload.collections[collection].config const tableName = this.tableNameMap.get(toSnakeCase(collectionConfig.slug)) @@ -54,8 +55,6 @@ export const deleteMany: DeleteMany = async function deleteMany( ) } - const db = await getTransaction(this, req) - await this.deleteWhere({ db, tableName, diff --git a/packages/drizzle/src/deleteOne.ts b/packages/drizzle/src/deleteOne.ts index c29170e1607..ab36edfcf1d 100644 --- a/packages/drizzle/src/deleteOne.ts +++ b/packages/drizzle/src/deleteOne.ts @@ -15,6 +15,7 @@ export const deleteOne: DeleteOne = async function deleteOne( this: DrizzleAdapter, { collection: collectionSlug, req, returning, select, where: whereArg }, ) { + const db = await getTransaction(this, req) const collection = this.payload.collections[collectionSlug].config const tableName = this.tableNameMap.get(toSnakeCase(collection.slug)) @@ -29,8 +30,6 @@ export const deleteOne: DeleteOne = async function deleteOne( where: whereArg, }) - const db = await getTransaction(this, req) - const selectDistinctResult = await selectDistinct({ adapter: this, db, diff --git a/packages/drizzle/src/deleteVersions.ts b/packages/drizzle/src/deleteVersions.ts index 2f0df95251a..a0aba3a6970 100644 --- a/packages/drizzle/src/deleteVersions.ts +++ b/packages/drizzle/src/deleteVersions.ts @@ -13,6 +13,8 @@ export const deleteVersions: DeleteVersions = async function deleteVersion( this: DrizzleAdapter, { collection: collectionSlug, globalSlug, locale, req, where: where }, ) { + const db = await getTransaction(this, req) + let tableName: string let fields: FlattenedField[] @@ -51,8 +53,6 @@ export const deleteVersions: DeleteVersions = async function deleteVersion( }) if (ids.length > 0) { - const db = await getTransaction(this, req) - await this.deleteWhere({ db, tableName, diff --git a/packages/drizzle/src/find/findMany.ts b/packages/drizzle/src/find/findMany.ts index 167f96f3a76..e991b55197a 100644 --- a/packages/drizzle/src/find/findMany.ts +++ b/packages/drizzle/src/find/findMany.ts @@ -37,6 +37,7 @@ export const findMany = async function find({ versions, where: whereArg, }: Args) { + const db = await getTransaction(adapter, req) let limit = limitArg let totalDocs: number let totalPages: number @@ -95,8 +96,6 @@ export const findMany = async function find({ } } - const db = await getTransaction(adapter, req) - const selectDistinctResult = await selectDistinct({ adapter, db, diff --git a/packages/drizzle/src/findDistinct.ts b/packages/drizzle/src/findDistinct.ts index 0759d801603..a7876f36623 100644 --- a/packages/drizzle/src/findDistinct.ts +++ b/packages/drizzle/src/findDistinct.ts @@ -9,6 +9,7 @@ import { getTransaction } from './utilities/getTransaction.js' import { DistinctSymbol } from './utilities/rawConstraint.js' export const findDistinct: FindDistinct = async function (this: DrizzleAdapter, args) { + const db = await getTransaction(this, args.req) const collectionConfig: SanitizedCollectionConfig = this.payload.collections[args.collection].config const page = args.page || 1 @@ -35,8 +36,6 @@ export const findDistinct: FindDistinct = async function (this: DrizzleAdapter, orderBy.pop() - const db = await getTransaction(this, args.req) - const selectDistinctResult = await selectDistinct({ adapter: this, db, diff --git a/packages/drizzle/src/updateGlobal.ts b/packages/drizzle/src/updateGlobal.ts index 959e99069d0..e97bd15ea9c 100644 --- a/packages/drizzle/src/updateGlobal.ts +++ b/packages/drizzle/src/updateGlobal.ts @@ -11,10 +11,10 @@ export async function updateGlobal>( this: DrizzleAdapter, { slug, data, req, returning, select }: UpdateGlobalArgs, ): Promise { + const db = await getTransaction(this, req) const globalConfig = this.payload.globals.config.find((config) => config.slug === slug) const tableName = this.tableNameMap.get(toSnakeCase(globalConfig.slug)) - const db = await getTransaction(this, req) const existingGlobal = await db.query[tableName].findFirst({}) const result = await upsertRow<{ globalType: string } & T>({ diff --git a/packages/drizzle/src/updateGlobalVersion.ts b/packages/drizzle/src/updateGlobalVersion.ts index 1961a9822ac..223e4096d14 100644 --- a/packages/drizzle/src/updateGlobalVersion.ts +++ b/packages/drizzle/src/updateGlobalVersion.ts @@ -27,6 +27,7 @@ export async function updateGlobalVersion( where: whereArg, }: UpdateGlobalVersionArgs, ): Promise> { + const db = await getTransaction(this, req) const globalConfig: SanitizedGlobalConfig = this.payload.globals.config.find( ({ slug }) => slug === global, ) @@ -46,8 +47,6 @@ export async function updateGlobalVersion( where: whereToUse, }) - const db = await getTransaction(this, req) - const result = await upsertRow>({ id, adapter: this, diff --git a/packages/drizzle/src/updateJobs.ts b/packages/drizzle/src/updateJobs.ts index 8969cf7bd1e..7463dab2480 100644 --- a/packages/drizzle/src/updateJobs.ts +++ b/packages/drizzle/src/updateJobs.ts @@ -23,6 +23,7 @@ export const updateJobs: UpdateJobs = async function updateMany( const whereToUse: Where = id ? { id: { equals: id } } : whereArg const limit = id ? 1 : limitArg + const db = await getTransaction(this, req) const collection = this.payload.collections['payload-jobs'].config const tableName = this.tableNameMap.get(toSnakeCase(collection.slug)) const sort = sortArg !== undefined && sortArg !== null ? sortArg : collection.defaultSort @@ -33,8 +34,6 @@ export const updateJobs: UpdateJobs = async function updateMany( }) if (useOptimizedUpsertRow && id) { - const db = await getTransaction(this, req) - const result = await upsertRow({ id, adapter: this, @@ -65,8 +64,6 @@ export const updateJobs: UpdateJobs = async function updateMany( return [] } - const db = await getTransaction(this, req) - const results = [] // TODO: We need to batch this to reduce the amount of db calls. This can get very slow if we are updating a lot of rows. diff --git a/packages/drizzle/src/updateMany.ts b/packages/drizzle/src/updateMany.ts index c4babe7f733..a0f8b773152 100644 --- a/packages/drizzle/src/updateMany.ts +++ b/packages/drizzle/src/updateMany.ts @@ -25,6 +25,7 @@ export const updateMany: UpdateMany = async function updateMany( where: whereToUse, }, ) { + const db = await getTransaction(this, req) const collection = this.payload.collections[collectionSlug].config const tableName = this.tableNameMap.get(toSnakeCase(collection.slug)) @@ -39,8 +40,6 @@ export const updateMany: UpdateMany = async function updateMany( where: whereToUse, }) - const db = await getTransaction(this, req) - let idsToUpdate: (number | string)[] = [] const selectDistinctResult = await selectDistinct({ diff --git a/packages/drizzle/src/updateOne.ts b/packages/drizzle/src/updateOne.ts index 879d4344527..8fddd9378fd 100644 --- a/packages/drizzle/src/updateOne.ts +++ b/packages/drizzle/src/updateOne.ts @@ -25,12 +25,11 @@ export const updateOne: UpdateOne = async function updateOne( where: whereArg, }, ) { + const db = await getTransaction(this, req) const collection = this.payload.collections[collectionSlug].config const tableName = this.tableNameMap.get(toSnakeCase(collection.slug)) let idToUpdate = id - const db = await getTransaction(this, req) - if (!idToUpdate) { const { joins, selectFields, where } = buildQuery({ adapter: this, diff --git a/packages/drizzle/src/updateVersion.ts b/packages/drizzle/src/updateVersion.ts index 1572a141f13..49350b7bf8c 100644 --- a/packages/drizzle/src/updateVersion.ts +++ b/packages/drizzle/src/updateVersion.ts @@ -27,6 +27,7 @@ export async function updateVersion( where: whereArg, }: UpdateVersionArgs, ): Promise> { + const db = await getTransaction(this, req) const collectionConfig: SanitizedCollectionConfig = this.payload.collections[collection].config const whereToUse = whereArg || { id: { equals: id } } const tableName = this.tableNameMap.get( @@ -43,8 +44,6 @@ export async function updateVersion( where: whereToUse, }) - const db = await getTransaction(this, req) - const result = await upsertRow>({ id, adapter: this, diff --git a/packages/next/src/utilities/initReq.ts b/packages/next/src/utilities/initReq.ts index 3a800f3f1da..6f6628d6771 100644 --- a/packages/next/src/utilities/initReq.ts +++ b/packages/next/src/utilities/initReq.ts @@ -140,14 +140,7 @@ export const initReq = async function ({ // This ensures parallel operations using the same cache key don't affect each other. return { ...result, - req: { - ...result.req, - ...(result.req?.context - ? { - context: { ...result.req.context }, - } - : {}), - }, + req: { ...result.req }, } }) } diff --git a/packages/next/src/views/Document/index.tsx b/packages/next/src/views/Document/index.tsx index 11f95067a28..20f333496c6 100644 --- a/packages/next/src/views/Document/index.tsx +++ b/packages/next/src/views/Document/index.tsx @@ -21,7 +21,7 @@ import { handleLivePreview, handlePreview } from '@payloadcms/ui/rsc' import { isEditing as getIsEditing } from '@payloadcms/ui/shared' import { buildFormState } from '@payloadcms/ui/utilities/buildFormState' import { notFound, redirect } from 'next/navigation.js' -import { isolateObjectProperty, logError } from 'payload' +import { logError } from 'payload' import { formatAdminURL } from 'payload/shared' import React from 'react' @@ -140,27 +140,6 @@ export const renderDocument = async ({ const isTrashedDoc = Boolean(doc && 'deletedAt' in doc && typeof doc?.deletedAt === 'string') - // CRITICAL FIX FOR RACE CONDITION: - // When running parallel operations with Promise.all, if they share the same req object - // and one operation calls initTransaction() which MUTATES req.transactionID, that mutation - // is visible to all parallel operations. This causes: - // 1. Operation A (e.g., getDocumentPermissions → docAccessOperation) calls initTransaction() - // which sets req.transactionID = Promise, then resolves it to a UUID - // 2. Operation B (e.g., getIsLocked) running in parallel receives the SAME req with the mutated transactionID - // 3. Operation A (does not even know that Operation B even exists and is stil using the transactionID) commits/ends its transaction - // 4. Operation B tries to use the now-expired session → MongoExpiredSessionError! - // - // Solution: Use isolateObjectProperty to create a Proxy that isolates the 'transactionID' property. - // This allows each operation to have its own transactionID without affecting the parent req. - // If parent req already has a transaction, preserve it (don't isolate). - // - // Note: We use isolateObjectProperty instead of shallow copy ({ ...req }) because: - // - Shallow copy would break tests that expect req.transactionID mutations to be visible to the caller - // - isolateObjectProperty creates a Proxy where transactionID mutations go to a delegate object - // - The parent req remains unmutated while each child operation can have its own transaction - const reqForPermissions = req.transactionID ? req : isolateObjectProperty(req, 'transactionID') - const reqForLockCheck = req.transactionID ? req : isolateObjectProperty(req, 'transactionID') - const [ docPreferences, { docPermissions, hasPublishPermission, hasSavePermission }, @@ -176,22 +155,22 @@ export const renderDocument = async ({ user, }), - // Get permissions - isolated transactionID prevents cross-contamination + // Get permissions getDocumentPermissions({ id: idFromArgs, collectionConfig, data: doc, globalConfig, - req: reqForPermissions, + req, }), - // Fetch document lock state - isolated transactionID prevents cross-contamination + // Fetch document lock state getIsLocked({ id: idFromArgs, collectionConfig, globalConfig, isEditing, - req: reqForLockCheck, + req, }), // get entity preferences diff --git a/packages/payload/src/utilities/createLocalReq.ts b/packages/payload/src/utilities/createLocalReq.ts index 0bfe979984e..26d9af6a96b 100644 --- a/packages/payload/src/utilities/createLocalReq.ts +++ b/packages/payload/src/utilities/createLocalReq.ts @@ -99,6 +99,11 @@ export const createLocalReq: CreateLocalReq = async ( { context, fallbackLocale, locale: localeArg, req = {} as PayloadRequest, urlSuffix, user }, payload, ): Promise => { + // CRITICAL: Create a shallow copy of req to prevent mutations from propagating + // to the original req object (which may be cached or shared across operations) + // This preserves any intentional transactionID while preventing mutation leakage + req = { ...req } + const localization = payload.config?.localization if (localization) { From 64a62b4758029f472ea601fe6fe78496ee5c2d0d Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Tue, 18 Nov 2025 11:05:28 -0800 Subject: [PATCH 31/42] Revert "Merge remote-tracking branch 'origin/fix/transaction-issues' into fix/update-field-access-control-after-save" This reverts commit 28d7bc71893ffb7960caf7284ac4d8ef91202ae8, reversing changes made to ee4c8e0b014756604aa410419ae7badd7d242f67. --- packages/next/src/utilities/initReq.ts | 84 ++++++++----------- .../payload/src/utilities/createLocalReq.ts | 5 -- 2 files changed, 37 insertions(+), 52 deletions(-) diff --git a/packages/next/src/utilities/initReq.ts b/packages/next/src/utilities/initReq.ts index 6f6628d6771..e2e4c7cbab9 100644 --- a/packages/next/src/utilities/initReq.ts +++ b/packages/next/src/utilities/initReq.ts @@ -94,53 +94,43 @@ export const initReq = async function ({ } }, 'global') - return reqCache - .get(async () => { - const { i18n, languageCode, payload, responseHeaders, user } = partialResult - - const { req: reqOverrides, ...optionsOverrides } = overrides || {} - - const req = await createLocalReq( - { - req: { - headers, - host: headers.get('host'), - i18n: i18n as I18n, - responseHeaders, - user, - ...(reqOverrides || {}), - }, - ...(optionsOverrides || {}), + return reqCache.get(async () => { + const { i18n, languageCode, payload, responseHeaders, user } = partialResult + + const { req: reqOverrides, ...optionsOverrides } = overrides || {} + + const req = await createLocalReq( + { + req: { + headers, + host: headers.get('host'), + i18n: i18n as I18n, + responseHeaders, + user, + ...(reqOverrides || {}), }, - payload, - ) - - const locale = await getRequestLocale({ - req, - }) - - req.locale = locale?.code - - const permissions = await getAccessResults({ - req, - }) - - return { - cookies, - headers, - languageCode, - locale, - permissions, - req, - } - }, key) - .then((result) => { - // CRITICAL: Create a shallow copy of req before returning to prevent - // mutations from propagating to the cached req object. - // This ensures parallel operations using the same cache key don't affect each other. - return { - ...result, - req: { ...result.req }, - } + ...(optionsOverrides || {}), + }, + payload, + ) + + const locale = await getRequestLocale({ + req, + }) + + req.locale = locale?.code + + const permissions = await getAccessResults({ + req, }) + + return { + cookies, + headers, + languageCode, + locale, + permissions, + req, + } + }, key) } diff --git a/packages/payload/src/utilities/createLocalReq.ts b/packages/payload/src/utilities/createLocalReq.ts index 26d9af6a96b..0bfe979984e 100644 --- a/packages/payload/src/utilities/createLocalReq.ts +++ b/packages/payload/src/utilities/createLocalReq.ts @@ -99,11 +99,6 @@ export const createLocalReq: CreateLocalReq = async ( { context, fallbackLocale, locale: localeArg, req = {} as PayloadRequest, urlSuffix, user }, payload, ): Promise => { - // CRITICAL: Create a shallow copy of req to prevent mutations from propagating - // to the original req object (which may be cached or shared across operations) - // This preserves any intentional transactionID while preventing mutation leakage - req = { ...req } - const localization = payload.config?.localization if (localization) { From a68e56cadb4726324c099a5044b71c98f25dd239 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Tue, 18 Nov 2025 11:05:41 -0800 Subject: [PATCH 32/42] Revert "Merge remote-tracking branch 'origin/fix/transaction-issues' into fix/update-field-access-control-after-save" This reverts commit ee4c8e0b014756604aa410419ae7badd7d242f67, reversing changes made to d10dfb2bef854992e45d93c95186497eec3bba51. --- .../src/transactions/commitTransaction.ts | 21 +++++------------- .../src/transactions/rollbackTransaction.ts | 21 +++++++++--------- .../db-mongodb/src/utilities/getSession.ts | 17 +------------- .../src/transactions/commitTransaction.ts | 22 ++++++++----------- .../src/transactions/rollbackTransaction.ts | 9 +++----- .../drizzle/src/utilities/getTransaction.ts | 3 --- 6 files changed, 30 insertions(+), 63 deletions(-) diff --git a/packages/db-mongodb/src/transactions/commitTransaction.ts b/packages/db-mongodb/src/transactions/commitTransaction.ts index c49f1c0eb00..6fcbdb838cd 100644 --- a/packages/db-mongodb/src/transactions/commitTransaction.ts +++ b/packages/db-mongodb/src/transactions/commitTransaction.ts @@ -4,30 +4,21 @@ import type { MongooseAdapter } from '../index.js' export const commitTransaction: CommitTransaction = async function commitTransaction( this: MongooseAdapter, - incomingID = '', + id, ) { - const transactionID = incomingID instanceof Promise ? await incomingID : incomingID - - if (!this.sessions[transactionID]) { + if (id instanceof Promise) { return } - if (!this.sessions[transactionID]?.inTransaction()) { - // Clean up the orphaned session reference - delete this.sessions[transactionID] + if (!this.sessions[id]?.inTransaction()) { return } - const session = this.sessions[transactionID] - - // Delete from registry FIRST to prevent race conditions - // This ensures other operations can't retrieve this session while we're ending it - delete this.sessions[transactionID] - - await session.commitTransaction() + await this.sessions[id].commitTransaction() try { - await session.endSession() + await this.sessions[id].endSession() } catch (_) { // ending sessions is only best effort and won't impact anything if it fails since the transaction was committed } + delete this.sessions[id] } diff --git a/packages/db-mongodb/src/transactions/rollbackTransaction.ts b/packages/db-mongodb/src/transactions/rollbackTransaction.ts index 7134054d9c1..78c6f353632 100644 --- a/packages/db-mongodb/src/transactions/rollbackTransaction.ts +++ b/packages/db-mongodb/src/transactions/rollbackTransaction.ts @@ -6,7 +6,13 @@ export const rollbackTransaction: RollbackTransaction = async function rollbackT this: MongooseAdapter, incomingID = '', ) { - const transactionID = incomingID instanceof Promise ? await incomingID : incomingID + let transactionID: number | string + + if (incomingID instanceof Promise) { + transactionID = await incomingID + } else { + transactionID = incomingID + } // if multiple operations are using the same transaction, the first will flow through and delete the session. // subsequent calls should be ignored. @@ -21,17 +27,12 @@ export const rollbackTransaction: RollbackTransaction = async function rollbackT return } - const session = this.sessions[transactionID] - - // Delete from registry FIRST to prevent race conditions - // This ensures other operations can't retrieve this session while we're aborting it - delete this.sessions[transactionID] - // the first call for rollback should be aborted and deleted causing any other operations with the same transaction to fail try { - await session.abortTransaction() - await session.endSession() - } catch (_error) { + await this.sessions[transactionID]?.abortTransaction() + await this.sessions[transactionID]?.endSession() + } catch (error) { // ignore the error as it is likely a race condition from multiple errors } + delete this.sessions[transactionID] } diff --git a/packages/db-mongodb/src/utilities/getSession.ts b/packages/db-mongodb/src/utilities/getSession.ts index b72d00a82c7..1bb0b8795df 100644 --- a/packages/db-mongodb/src/utilities/getSession.ts +++ b/packages/db-mongodb/src/utilities/getSession.ts @@ -22,21 +22,6 @@ export async function getSession( } if (transactionID) { - const session = db.sessions[transactionID] - - // Defensive check for race conditions where: - // 1. Session was retrieved from db.sessions - // 2. Another operation committed/rolled back and ended the session - // 3. This operation tries to use the now-ended session - // Note: This shouldn't normally happen as sessions are deleted from db.sessions - // after commit/rollback, but can occur due to async timing where we hold - // a reference to a session object that gets ended before we use it. - if (session && !session.inTransaction()) { - // Clean up the orphaned session reference - delete db.sessions[transactionID] - return undefined - } - - return session + return db.sessions[transactionID] } } diff --git a/packages/drizzle/src/transactions/commitTransaction.ts b/packages/drizzle/src/transactions/commitTransaction.ts index d5d0b0a9d36..eed2c0f4ecb 100644 --- a/packages/drizzle/src/transactions/commitTransaction.ts +++ b/packages/drizzle/src/transactions/commitTransaction.ts @@ -1,24 +1,20 @@ import type { CommitTransaction } from 'payload' -export const commitTransaction: CommitTransaction = async function commitTransaction( - incomingID = '', -) { - const transactionID = incomingID instanceof Promise ? await incomingID : incomingID +export const commitTransaction: CommitTransaction = async function commitTransaction(id) { + if (id instanceof Promise) { + return + } // if the session was deleted it has already been aborted - if (!this.sessions[transactionID]) { + if (!this.sessions[id]) { return } - const session = this.sessions[transactionID] - - // Delete from registry FIRST to prevent race conditions - // This ensures other operations can't retrieve this session while we're ending it - delete this.sessions[transactionID] - try { - await session.resolve() + await this.sessions[id].resolve() } catch (_) { - await session.reject() + await this.sessions[id].reject() } + + delete this.sessions[id] } diff --git a/packages/drizzle/src/transactions/rollbackTransaction.ts b/packages/drizzle/src/transactions/rollbackTransaction.ts index 75b838604a1..143fbefc3f1 100644 --- a/packages/drizzle/src/transactions/rollbackTransaction.ts +++ b/packages/drizzle/src/transactions/rollbackTransaction.ts @@ -11,12 +11,9 @@ export const rollbackTransaction: RollbackTransaction = async function rollbackT return } - const session = this.sessions[transactionID] + // end the session promise in failure by calling reject + await this.sessions[transactionID].reject() - // Delete from registry FIRST to prevent race conditions - // This ensures other operations can't retrieve this session while we're ending it + // delete the session causing any other operations with the same transaction to fail delete this.sessions[transactionID] - - // end the session promise in failure by calling reject - await session.reject() } diff --git a/packages/drizzle/src/utilities/getTransaction.ts b/packages/drizzle/src/utilities/getTransaction.ts index 21662c91439..910674fd9f7 100644 --- a/packages/drizzle/src/utilities/getTransaction.ts +++ b/packages/drizzle/src/utilities/getTransaction.ts @@ -4,9 +4,6 @@ import type { DrizzleAdapter } from '../types.js' /** * Returns current db transaction instance from req or adapter.drizzle itself - * - * If a transaction session doesn't exist (e.g., it was already committed/rolled back), - * falls back to the default adapter.drizzle instance to prevent errors. */ export const getTransaction = async ( adapter: T, From ea3cb08f400aedbe9393f34d09435921a9dc72d3 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Tue, 18 Nov 2025 17:58:26 -0800 Subject: [PATCH 33/42] perf: cache access control where query db calls --- .../getEntityPermissions.ts | 229 ++++++++++-------- 1 file changed, 130 insertions(+), 99 deletions(-) diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts index b66de33b3a8..aec48ea80a9 100644 --- a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -1,18 +1,22 @@ +import { isDeepStrictEqual } from 'util' + import type { BlockPermissions, CollectionPermission, FieldsPermissions, GlobalPermission, + Permission, } from '../../auth/types.js' import type { SanitizedCollectionConfig, TypeWithID } from '../../collections/config/types.js' -import type { Access } from '../../config/types.js' import type { SanitizedGlobalConfig } from '../../globals/config/types.js' import type { BlockSlug, DefaultDocumentIDType } from '../../index.js' -import type { AllOperations, JsonObject, PayloadRequest } from '../../types/index.js' +import type { AllOperations, JsonObject, PayloadRequest, Where } from '../../types/index.js' import { entityDocExists } from './entityDocExists.js' import { populateFieldPermissions } from './populateFieldPermissions.js' +type WhereQueryCache = { result: Promise; where: Where }[] + export type BlockReferencesPermissions = Record< BlockSlug, BlockPermissions | Promise @@ -99,37 +103,35 @@ export async function getEntityPermissions 0 - const data: JsonObject | Promise | undefined = ( - hasData - ? _data - : fetchData - ? (async () => { - if (entityType === 'global') { - return req.payload.findGlobal({ - slug: entity.slug, - depth: 0, - fallbackLocale: null, - locale, - overrideAccess: true, - req, - }) - } - - if (entityType === 'collection') { - return req.payload.findByID({ - id: id!, - collection: entity.slug, - depth: 0, - fallbackLocale: null, - locale, - overrideAccess: true, - req, - trash: true, - }) - } - })() - : undefined - ) as JsonObject | Promise + const data: JsonObject | undefined = hasData + ? _data + : fetchData + ? await (async () => { + if (entityType === 'global') { + return req.payload.findGlobal({ + slug: entity.slug, + depth: 0, + fallbackLocale: null, + locale, + overrideAccess: true, + req, + }) + } + + if (entityType === 'collection') { + return req.payload.findByID({ + id: id!, + collection: entity.slug, + depth: 0, + fallbackLocale: null, + locale, + overrideAccess: true, + req, + trash: true, + }) + } + })() + : undefined const isLoggedIn = !!user @@ -139,9 +141,14 @@ export async function getEntityPermissions - const entityAccessPromises: Promise[] = [] const promises: Promise[] = [] + // Phase 1: Resolve all access functions to get where queries + const accessResults: { + operation: keyof typeof entity.access + result: Promise + }[] = [] + for (const _operation of operations) { const operation = _operation as keyof typeof entity.access const accessFunction = entity.access[operation] @@ -151,20 +158,10 @@ export async function getEntityPermissions, + }) } else { entityPermissions[operation] = { permission: isLoggedIn, @@ -173,15 +170,44 @@ export async function getEntityPermissions ({ + operation: item.operation, + result: await item.result, + })), + ) - const resolvedData = await data + // Phase 2: Process where queries with cache and resolve in parallel + const whereQueryCache: WhereQueryCache = [] + const wherePromises: Promise[] = [] + + for (const { operation, result: accessResult } of resolvedAccessResults) { + if (typeof accessResult === 'object') { + processWhereQuery({ + id, + slug: entity.slug, + accessResult, + entityPermissions, + entityType, + fetchData, + locale, + operation, + req, + wherePromises, + whereQueryCache, + }) + } else if (entityPermissions[operation]?.permission !== false) { + entityPermissions[operation] = { permission: !!accessResult } + } + } + + // Await all where query DB calls in parallel + await Promise.all(wherePromises) populateFieldPermissions({ blockReferencesPermissions, - data: resolvedData, + data, fields: entity.fields, operations, parentPermissionsObject: entityPermissions, @@ -211,60 +237,65 @@ export async function getEntityPermissions | undefined - disableWhere?: boolean +const processWhereQuery = ({ + id, + slug, + accessResult, + entityPermissions, + entityType, + fetchData, + locale, + operation, + req, + wherePromises, + whereQueryCache, +}: { + accessResult: Where + entityPermissions: CollectionPermission | GlobalPermission entityType: 'collection' | 'global' fetchData: boolean id?: DefaultDocumentIDType locale?: string operation: Extract - permissionsObject: CollectionPermission | GlobalPermission req: PayloadRequest slug: string -}) => Promise + wherePromises: Promise[] + whereQueryCache: WhereQueryCache +}): void => { + if (fetchData) { + // Check cache for identical where query using deep comparison + let cached = whereQueryCache.find((entry) => isDeepStrictEqual(entry.where, accessResult)) -const createEntityAccessPromise: CreateEntityAccessPromise = async ({ - id, - slug, - access, - data, - disableWhere = false, - entityType, - fetchData, - locale, - operation, - permissionsObject, - req, -}) => { - // Await data - if it's a Promise it resolves, if not it returns immediately - const resolvedData = await data - - const accessResult = await access({ id, data: resolvedData, req }) - - // Where query was returned from access function => check if document is returned when querying with where - if (typeof accessResult === 'object' && !disableWhere) { - permissionsObject[operation] = { - permission: fetchData - ? await entityDocExists({ - id, - slug, - entityType, - locale, - operation, - req, - where: accessResult, - }) - : // TODO: 4.0: Investigate defaulting to `false` here, if where query is returned but ignored as we don't - // have the document data available. This seems more secure. - // Alternatively, we could set permission to a third state, like 'unknown'. - true, - where: accessResult, - } - } else if (permissionsObject[operation]?.permission !== false) { - permissionsObject[operation] = { - permission: !!accessResult, + if (!cached) { + // Cache miss - start DB query (don't await) + cached = { + result: entityDocExists({ + id, + slug, + entityType, + locale, + operation, + req, + where: accessResult, + }), + where: accessResult, + } + whereQueryCache.push(cached) } + + // Defer resolution to Promise.all (cache hits reuse same promise) + wherePromises.push( + cached.result.then((hasPermission) => { + entityPermissions[operation] = { + permission: hasPermission, + where: accessResult, + } as Permission + }), + ) + } else { + // TODO: 4.0: Investigate defaulting to `false` here, if where query is returned but ignored as we don't + // have the document data available. This seems more secure. + // Alternatively, we could set permission to a third state, like 'unknown'. + entityPermissions[operation] = { permission: true, where: accessResult } as Permission } } From bd01d9aa6c39f8ebe08dc82c509f31c59515ec21 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Tue, 18 Nov 2025 18:51:13 -0800 Subject: [PATCH 34/42] chore: skip faulty test --- test/auth/e2e.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/auth/e2e.spec.ts b/test/auth/e2e.spec.ts index 01f8f3ffb27..170991e1ad9 100644 --- a/test/auth/e2e.spec.ts +++ b/test/auth/e2e.spec.ts @@ -185,7 +185,14 @@ describe('Auth', () => { await saveDocAndAssert(page, '#action-save') }) - test('should protect field schemas behind authentication', async () => { + // TODO: This test is unreliable. During development, the bundle sent to the client will include debug information. + // For example, arguments passed from one RSC to another RSC may be sent to the client by Next.js for debug reasons. + // In production however, this would never happen. + // In this case, simply using console.log on the permissions object + // may cause `shouldNotShowInClientConfigUnlessAuthenticated` to be included in the bundle, + // even though we're never actually sending it to the client. + // We'll need to run this test in production to ensure it passes. + test.skip('should protect field schemas behind authentication', async () => { await logout(page, serverURL) // Inspect the page source (before authentication) From ba28f99fd3693193e5faa5f7c344281008391d16 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Tue, 18 Nov 2025 19:40:05 -0800 Subject: [PATCH 35/42] test: access control tests for db call caching --- packages/payload/src/auth/types.ts | 18 +- packages/payload/src/index.ts | 2 + .../getEntityPermissions.ts | 2 + test/access-control/config.postgreslogs.ts | 24 + test/access-control/config.ts | 901 +--------------- test/access-control/getConfig.ts | 980 ++++++++++++++++++ test/access-control/int.spec.ts | 4 +- test/access-control/payload-types.ts | 207 ++-- test/access-control/postgres-logs.int.spec.ts | 264 +++++ test/access-control/shared.ts | 3 + test/auth/payload-types.ts | 27 + 11 files changed, 1450 insertions(+), 982 deletions(-) create mode 100644 test/access-control/config.postgreslogs.ts create mode 100644 test/access-control/getConfig.ts create mode 100644 test/access-control/postgres-logs.int.spec.ts diff --git a/packages/payload/src/auth/types.ts b/packages/payload/src/auth/types.ts index 197bac6aad4..77d3d13b313 100644 --- a/packages/payload/src/auth/types.ts +++ b/packages/payload/src/auth/types.ts @@ -40,10 +40,10 @@ export type SanitizedBlocksPermissions = export type FieldPermissions = { blocks?: BlocksPermissions - create: Permission + create?: Permission fields?: FieldsPermissions - read: Permission - update: Permission + read?: Permission + update?: Permission } export type SanitizedFieldPermissions = @@ -63,14 +63,14 @@ export type SanitizedFieldsPermissions = | true export type CollectionPermission = { - create: Permission - delete: Permission + create?: Permission + delete?: Permission fields: FieldsPermissions - read: Permission + read?: Permission readVersions?: Permission // Auth-enabled Collections only unlock?: Permission - update: Permission + update?: Permission } export type SanitizedCollectionPermission = { @@ -86,9 +86,9 @@ export type SanitizedCollectionPermission = { export type GlobalPermission = { fields: FieldsPermissions - read: Permission + read?: Permission readVersions?: Permission - update: Permission + update?: Permission } export type SanitizedGlobalPermission = { diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 67991eda800..dfd1f17e9e1 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1740,6 +1740,8 @@ export { formatErrors } from './utilities/formatErrors.js' export { formatLabels, formatNames, toWords } from './utilities/formatLabels.js' export { getBlockSelect } from './utilities/getBlockSelect.js' export { getCollectionIDFieldTypes } from './utilities/getCollectionIDFieldTypes.js' +export { getEntityPermissions } from './utilities/getEntityPermissions/getEntityPermissions.js' +export { getEntityPolicies } from './utilities/getEntityPolicies.js' export { getFieldByPath } from './utilities/getFieldByPath.js' export { getObjectDotNotation } from './utilities/getObjectDotNotation.js' export { getRequestLanguage } from './utilities/getRequestLanguage.js' diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts index aec48ea80a9..7a0840f147b 100644 --- a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -80,6 +80,8 @@ const topLevelGlobalPermissions = ['read', 'readVersions', 'update'] * We cannot include siblingData or blockData here, as we do not have siblingData available once we reach block or array * rows, as we're calculating schema permissions, which do not include individual rows. * For consistency, it's thus better to never include the siblingData and blockData + * + * @internal - this function may change or be removed in a minor release. */ export async function getEntityPermissions( args: Args, diff --git a/test/access-control/config.postgreslogs.ts b/test/access-control/config.postgreslogs.ts new file mode 100644 index 00000000000..a7d97e7c294 --- /dev/null +++ b/test/access-control/config.postgreslogs.ts @@ -0,0 +1,24 @@ +/* eslint-disable no-restricted-exports */ +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { getConfig } from './getConfig.js' + +const config = getConfig() + +import { postgresAdapter } from '@payloadcms/db-postgres' + +export const databaseAdapter = postgresAdapter({ + pool: { + connectionString: process.env.POSTGRES_URL || 'postgres://127.0.0.1:5432/payloadtests', + }, + logger: true, +}) + +export default buildConfigWithDefaults( + { + ...config, + db: databaseAdapter, + }, + { + disableAutoLogin: true, + }, +) diff --git a/test/access-control/config.ts b/test/access-control/config.ts index 3db42e81e1c..8340359cbe0 100644 --- a/test/access-control/config.ts +++ b/test/access-control/config.ts @@ -1,899 +1,6 @@ -import { fileURLToPath } from 'node:url' -import path from 'path' -const filename = fileURLToPath(import.meta.url) -const dirname = path.dirname(filename) -import type { FieldAccess, Where } from 'payload' - -import { buildEditorState, type DefaultNodeTypes } from '@payloadcms/richtext-lexical' - -import type { Config, User } from './payload-types.js' - import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' -import { devUser } from '../credentials.js' -import { Auth } from './collections/Auth/index.js' -import { BlocksFieldAccess } from './collections/BlocksFieldAccess/index.js' -import { Disabled } from './collections/Disabled/index.js' -import { Hooks } from './collections/hooks/index.js' -import { ReadRestricted } from './collections/ReadRestricted/index.js' -import { seedReadRestricted } from './collections/ReadRestricted/seed.js' -import { Regression1 } from './collections/Regression-1/index.js' -import { Regression2 } from './collections/Regression-2/index.js' -import { RichText } from './collections/RichText/index.js' -import { - blocksFieldAccessSlug, - createNotUpdateCollectionSlug, - docLevelAccessSlug, - firstArrayText, - fullyRestrictedSlug, - hiddenAccessCountSlug, - hiddenAccessSlug, - hiddenFieldsSlug, - nonAdminEmail, - publicUserEmail, - publicUsersSlug, - readNotUpdateGlobalSlug, - readOnlyGlobalSlug, - readOnlySlug, - relyOnRequestHeadersSlug, - restrictedVersionsAdminPanelSlug, - restrictedVersionsSlug, - secondArrayText, - siblingDataSlug, - slug, - unrestrictedSlug, - userRestrictedCollectionSlug, - userRestrictedGlobalSlug, -} from './shared.js' - -const openAccess = { - create: () => true, - delete: () => true, - read: () => true, - update: () => true, -} - -const PublicReadabilityAccess: FieldAccess = ({ req: { user }, siblingData }) => { - if (user) { - return true - } - if (siblingData?.allowPublicReadability) { - return true - } - - return false -} - -export const requestHeaders = new Headers({ authorization: 'Bearer testBearerToken' }) -const UseRequestHeadersAccess: FieldAccess = ({ req: { headers } }) => { - return !!headers && headers.get('authorization') === requestHeaders.get('authorization') -} - -function isUser(user?: Config['user']): user is { - collection: 'users' -} & User { - if (!user) { - return false - } - return user?.collection === 'users' -} - -export default buildConfigWithDefaults( - { - admin: { - autoLogin: false, - user: 'users', - importMap: { - baseDir: path.resolve(dirname), - }, - }, - blocks: [ - { - slug: 'titleblock', - fields: [ - { - type: 'text', - name: 'title', - }, - ], - }, - ], - collections: [ - { - slug: 'users', - access: { - // admin: () => true, - admin: async ({ req }) => { - if (req.user?.email === nonAdminEmail) { - return false - } - - return new Promise((resolve) => { - // Simulate a request to an external service to determine access, i.e. another instance of Payload - setTimeout(resolve, 50, true) // set to 'true' or 'false' here to simulate the response - }) - }, - unlock: ({ req }) => { - if (req.user && req.user.collection === 'users') { - // admin users can only unlock themselves - return { - id: { - equals: req.user.id, - }, - } - } - - return false - }, - }, - auth: true, - fields: [ - { - name: 'roles', - type: 'select', - access: { - create: ({ req }) => isUser(req.user) && req.user?.roles?.includes('admin'), - read: () => false, - update: ({ req }) => { - return isUser(req.user) && req.user?.roles?.includes('admin') - }, - }, - defaultValue: ['user'], - hasMany: true, - options: ['admin', 'user'], - }, - ], - }, - { - slug: publicUsersSlug, - auth: true, - fields: [], - }, - { - slug, - access: { - ...openAccess, - update: () => false, - }, - fields: [ - { - name: 'restrictedField', - type: 'text', - access: { - read: () => false, - update: () => false, - }, - }, - { - name: 'group', - type: 'group', - fields: [ - { - name: 'restrictedGroupText', - type: 'text', - access: { - create: () => false, - read: () => false, - update: () => false, - }, - }, - ], - }, - { - type: 'row', - fields: [ - { - name: 'restrictedRowText', - type: 'text', - access: { - create: () => false, - read: () => false, - update: () => false, - }, - }, - ], - }, - { - type: 'collapsible', - fields: [ - { - name: 'restrictedCollapsibleText', - type: 'text', - access: { - create: () => false, - read: () => false, - update: () => false, - }, - }, - ], - label: 'Access', - }, - ], - }, - { - slug: unrestrictedSlug, - fields: [ - { - name: 'name', - type: 'text', - }, - { - name: 'info', - type: 'group', - fields: [ - { - name: 'title', - type: 'text', - }, - { - name: 'description', - type: 'textarea', - }, - ], - }, - { - name: 'userRestrictedDocs', - type: 'relationship', - hasMany: true, - relationTo: userRestrictedCollectionSlug, - }, - { - name: 'createNotUpdateDocs', - type: 'relationship', - hasMany: true, - relationTo: createNotUpdateCollectionSlug, - }, - ], - }, - { - slug: 'relation-restricted', - access: { - read: () => true, - }, - fields: [ - { - name: 'name', - type: 'text', - }, - { - name: 'post', - type: 'relationship', - relationTo: slug, - }, - ], - }, - { - slug: fullyRestrictedSlug, - access: { - create: () => false, - delete: () => false, - read: () => false, - update: () => false, - }, - fields: [ - { - name: 'name', - type: 'text', - }, - ], - }, - { - slug: readOnlySlug, - access: { - create: () => false, - delete: () => false, - read: () => true, - update: () => false, - }, - fields: [ - { - name: 'name', - type: 'text', - }, - ], - }, - { - slug: userRestrictedCollectionSlug, - access: { - create: () => true, - delete: () => false, - read: () => true, - update: ({ req }) => ({ - name: { - equals: req.user?.email, - }, - }), - }, - admin: { - useAsTitle: 'name', - }, - fields: [ - { - name: 'name', - type: 'text', - }, - ], - }, - { - slug: createNotUpdateCollectionSlug, - access: { - create: () => true, - delete: () => false, - read: () => true, - update: () => false, - }, - admin: { - useAsTitle: 'name', - }, - fields: [ - { - name: 'name', - type: 'text', - }, - ], - }, - { - slug: restrictedVersionsSlug, - access: { - read: ({ req: { user } }) => { - if (user) { - return true - } - - return { - hidden: { - not_equals: true, - }, - } - }, - readVersions: ({ req: { user } }) => { - if (user) { - return true - } - - return { - 'version.hidden': { - not_equals: true, - }, - } - }, - }, - fields: [ - { - name: 'name', - type: 'text', - }, - { - name: 'hidden', - type: 'checkbox', - hidden: true, - }, - ], - versions: true, - }, - { - slug: restrictedVersionsAdminPanelSlug, - access: { - read: ({ req: { user } }) => { - if (user) { - return true - } - return false - }, - readVersions: () => { - return { - 'version.hidden': { - not_equals: true, - }, - } - }, - }, - fields: [ - { - name: 'name', - type: 'text', - }, - { - name: 'hidden', - type: 'checkbox', - }, - ], - versions: true, - }, - { - slug: siblingDataSlug, - access: openAccess, - fields: [ - { - name: 'array', - type: 'array', - fields: [ - { - type: 'row', - fields: [ - { - name: 'allowPublicReadability', - type: 'checkbox', - label: 'Allow Public Readability', - }, - { - name: 'text', - type: 'text', - access: { - read: PublicReadabilityAccess, - }, - }, - ], - }, - ], - }, - ], - }, - { - slug: relyOnRequestHeadersSlug, - access: { - create: UseRequestHeadersAccess, - delete: UseRequestHeadersAccess, - read: UseRequestHeadersAccess, - update: UseRequestHeadersAccess, - }, - fields: [ - { - name: 'name', - type: 'text', - }, - ], - }, - { - slug: docLevelAccessSlug, - access: { - delete: () => ({ - and: [ - { - approvedForRemoval: { - equals: true, - }, - }, - ], - }), - }, - fields: [ - { - name: 'approvedForRemoval', - type: 'checkbox', - defaultValue: false, - }, - { - name: 'approvedTitle', - type: 'text', - access: { - update: (args) => { - if (args?.doc?.lockTitle) { - return false - } - return true - }, - }, - localized: true, - }, - { - name: 'lockTitle', - type: 'checkbox', - defaultValue: false, - }, - ], - labels: { - plural: 'Doc Level Access', - singular: 'Doc Level Access', - }, - }, - { - slug: hiddenFieldsSlug, - access: openAccess, - fields: [ - { - name: 'title', - type: 'text', - }, - { - name: 'partiallyHiddenGroup', - type: 'group', - fields: [ - { - name: 'name', - type: 'text', - }, - { - name: 'value', - type: 'text', - hidden: true, - }, - ], - }, - { - name: 'partiallyHiddenArray', - type: 'array', - fields: [ - { - name: 'name', - type: 'text', - }, - { - name: 'value', - type: 'text', - hidden: true, - }, - ], - }, - { - name: 'hidden', - type: 'checkbox', - hidden: true, - }, - { - name: 'hiddenWithDefault', - type: 'text', - hidden: true, - defaultValue: 'default value', - }, - ], - }, - { - slug: hiddenAccessSlug, - access: { - read: ({ req: { user } }) => { - if (user) { - return true - } - - return { - hidden: { - not_equals: true, - }, - } - }, - }, - fields: [ - { - name: 'title', - type: 'text', - required: true, - }, - { - name: 'hidden', - type: 'checkbox', - hidden: true, - }, - ], - }, - { - slug: hiddenAccessCountSlug, - access: { - read: ({ req: { user } }) => { - if (user) { - return true - } - - return { - hidden: { - not_equals: true, - }, - } - }, - }, - fields: [ - { - name: 'title', - type: 'text', - required: true, - }, - { - name: 'hidden', - type: 'checkbox', - hidden: true, - }, - ], - }, - { - slug: 'fields-and-top-access', - access: { - readVersions: () => ({ - 'version.secret': { - equals: 'will-success-access-read', - }, - }), - read: () => ({ - secret: { - equals: 'will-success-access-read', - }, - }), - }, - versions: { drafts: true }, - fields: [ - { - type: 'text', - name: 'secret', - access: { read: () => false }, - }, - ], - }, - BlocksFieldAccess, - Disabled, - RichText, - Regression1, - Regression2, - Hooks, - Auth, - ReadRestricted, - { - slug: 'field-restricted-update-based-on-data', - fields: [ - { - name: 'restricted', - type: 'text', - access: { - update: ({ data }) => { - return !data?.isRestricted - }, - }, - }, - { - name: 'doesNothing', - type: 'checkbox', - }, - { - name: 'isRestricted', - type: 'checkbox', - }, - ], - }, - ], - globals: [ - { - slug: 'settings', - admin: { - components: { - elements: { - SaveButton: '/TestButton.js#TestButton', - }, - }, - }, - fields: [ - { - name: 'test', - type: 'checkbox', - label: 'Allow access to test global', - }, - ], - }, - { - slug: 'test', - access: { - read: async ({ req: { payload } }) => { - const access = await payload.findGlobal({ slug: 'settings' }) - return Boolean(access.test) - }, - }, - fields: [], - }, - { - slug: readOnlyGlobalSlug, - access: { - read: () => true, - update: () => false, - }, - fields: [ - { - name: 'name', - type: 'text', - }, - ], - }, - { - slug: userRestrictedGlobalSlug, - access: { - read: () => true, - update: ({ data, req }) => data?.name === req.user?.email, - }, - fields: [ - { - name: 'name', - type: 'text', - }, - ], - }, - { - slug: readNotUpdateGlobalSlug, - access: { - read: () => true, - update: () => false, - }, - fields: [ - { - name: 'name', - type: 'text', - }, - ], - }, - ], - onInit: async (payload) => { - await payload.create({ - collection: 'users', - data: { - email: devUser.email, - password: devUser.password, - }, - }) - - await payload.create({ - collection: 'users', - data: { - email: nonAdminEmail, - password: 'test', - }, - }) - - await payload.create({ - collection: publicUsersSlug, - data: { - email: publicUserEmail, - password: 'test', - }, - }) - - await payload.create({ - collection: slug, - data: { - restrictedField: 'restricted', - }, - }) - - await payload.create({ - collection: readOnlySlug, - data: { - name: 'read-only', - }, - }) - - await payload.create({ - collection: blocksFieldAccessSlug, - data: { - title: 'Blocks Field Access Test Document', - editableBlocks: [ - { - blockType: 'testBlock', - title: 'Editable Block', - content: 'This block should be fully editable', - }, - ], - readOnlyBlocks: [ - { - blockType: 'testBlock2', - title: 'Read-Only Block', - content: 'This block should be read-only due to field access control', - }, - ], - editableBlockRefs: [ - { - blockType: 'titleblock', - title: 'Editable Block Reference', - }, - ], - readOnlyBlockRefs: [ - { - blockType: 'titleblock', - title: 'Read-Only Block Reference', - }, - ], - tabReadOnlyTest: { - tabReadOnlyBlocks: [ - { - blockType: 'testBlock3', - title: 'Tab Read-Only Block', - content: 'This block is read-only and inside a tab', - }, - ], - tabReadOnlyBlockRefs: [ - { - blockType: 'titleblock', - title: 'Tab Read-Only Block Reference', - }, - ], - }, - }, - }) - - await payload.create({ - collection: restrictedVersionsSlug, - data: { - name: 'versioned', - }, - }) - - await payload.create({ - collection: siblingDataSlug, - data: { - array: [ - { - allowPublicReadability: true, - text: firstArrayText, - }, - { - allowPublicReadability: false, - text: secondArrayText, - }, - ], - }, - }) - - await payload.updateGlobal({ - slug: userRestrictedGlobalSlug, - data: { - name: 'dev@payloadcms.com', - }, - }) - - await payload.create({ - collection: 'regression1', - data: { - richText4: buildEditorState({ text: 'Text1' }), - array: [{ art: buildEditorState({ text: 'Text2' }) }], - arrayWithAccessFalse: [ - { richText6: buildEditorState({ text: 'Text3' }) }, - ], - group1: { - text: 'Text4', - richText1: buildEditorState({ text: 'Text5' }), - }, - blocks: [ - { - blockType: 'myBlock3', - richText7: buildEditorState({ text: 'Text6' }), - blockName: 'My Block 1', - }, - ], - blocks3: [ - { - blockType: 'myBlock2', - richText5: buildEditorState({ text: 'Text7' }), - blockName: 'My Block 2', - }, - ], - tab1: { - richText2: buildEditorState({ text: 'Text8' }), - blocks2: [ - { - blockType: 'myBlock', - richText3: buildEditorState({ text: 'Text9' }), - blockName: 'My Block 3', - }, - ], - }, - }, - }) - - await payload.create({ - collection: 'regression2', - data: { - array: [ - { - richText2: buildEditorState({ text: 'Text1' }), - }, - ], - group: { - text: 'Text2', - richText1: buildEditorState({ text: 'Text3' }), - }, - }, - }) +import { getConfig } from './getConfig.js' - // Seed read-restricted collection - await seedReadRestricted(payload) - }, - typescript: { - outputFile: path.resolve(dirname, 'payload-types.ts'), - }, - }, - { - disableAutoLogin: true, - }, -) +export default buildConfigWithDefaults(getConfig(), { + disableAutoLogin: true, +}) diff --git a/test/access-control/getConfig.ts b/test/access-control/getConfig.ts new file mode 100644 index 00000000000..c4c6668e48e --- /dev/null +++ b/test/access-control/getConfig.ts @@ -0,0 +1,980 @@ +import { fileURLToPath } from 'node:url' +import path from 'path' +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) +import type { Config, FieldAccess } from 'payload' + +import { buildEditorState, type DefaultNodeTypes } from '@payloadcms/richtext-lexical' + +import type { User } from './payload-types.js' + +import { devUser } from '../credentials.js' +import { Auth } from './collections/Auth/index.js' +import { BlocksFieldAccess } from './collections/BlocksFieldAccess/index.js' +import { Disabled } from './collections/Disabled/index.js' +import { Hooks } from './collections/hooks/index.js' +import { ReadRestricted } from './collections/ReadRestricted/index.js' +import { seedReadRestricted } from './collections/ReadRestricted/seed.js' +import { Regression1 } from './collections/Regression-1/index.js' +import { Regression2 } from './collections/Regression-2/index.js' +import { RichText } from './collections/RichText/index.js' +import { + blocksFieldAccessSlug, + createNotUpdateCollectionSlug, + docLevelAccessSlug, + firstArrayText, + fullyRestrictedSlug, + hiddenAccessCountSlug, + hiddenAccessSlug, + hiddenFieldsSlug, + nonAdminEmail, + publicUserEmail, + publicUsersSlug, + readNotUpdateGlobalSlug, + readOnlyGlobalSlug, + readOnlySlug, + relyOnRequestHeadersSlug, + restrictedVersionsAdminPanelSlug, + restrictedVersionsSlug, + secondArrayText, + siblingDataSlug, + slug, + unrestrictedSlug, + userRestrictedCollectionSlug, + userRestrictedGlobalSlug, +} from './shared.js' + +const openAccess = { + create: () => true, + delete: () => true, + read: () => true, + update: () => true, +} + +const PublicReadabilityAccess: FieldAccess = ({ req: { user }, siblingData }) => { + if (user) { + return true + } + if (siblingData?.allowPublicReadability) { + return true + } + + return false +} + +export const requestHeaders = new Headers({ authorization: 'Bearer testBearerToken' }) +const UseRequestHeadersAccess: FieldAccess = ({ req: { headers } }) => { + return !!headers && headers.get('authorization') === requestHeaders.get('authorization') +} + +function isUser(user?: any): user is { + collection: 'users' +} & User { + if (!user) { + return false + } + return user?.collection === 'users' +} + +export const getConfig: () => Partial = () => ({ + admin: { + autoLogin: false, + user: 'users', + importMap: { + baseDir: path.resolve(dirname), + }, + }, + blocks: [ + { + slug: 'titleblock', + fields: [ + { + type: 'text', + name: 'title', + }, + ], + }, + ], + collections: [ + { + slug: 'users', + access: { + // admin: () => true, + admin: async ({ req }) => { + if (req.user?.email === nonAdminEmail) { + return false + } + + return new Promise((resolve) => { + // Simulate a request to an external service to determine access, i.e. another instance of Payload + setTimeout(resolve, 50, true) // set to 'true' or 'false' here to simulate the response + }) + }, + unlock: ({ req }) => { + if (req.user && req.user.collection === 'users') { + // admin users can only unlock themselves + return { + id: { + equals: req.user.id, + }, + } + } + + return false + }, + }, + auth: true, + fields: [ + { + name: 'roles', + type: 'select', + access: { + create: ({ req }) => Boolean(isUser(req.user) && req.user?.roles?.includes('admin')), + read: () => false, + update: ({ req }) => { + return Boolean(isUser(req.user) && req.user?.roles?.includes('admin')) + }, + }, + defaultValue: ['user'], + hasMany: true, + options: ['admin', 'user'], + }, + ], + }, + { + slug: publicUsersSlug, + auth: true, + fields: [], + }, + { + slug, + access: { + ...openAccess, + update: () => false, + }, + fields: [ + { + name: 'restrictedField', + type: 'text', + access: { + read: () => false, + update: () => false, + }, + }, + { + name: 'group', + type: 'group', + fields: [ + { + name: 'restrictedGroupText', + type: 'text', + access: { + create: () => false, + read: () => false, + update: () => false, + }, + }, + ], + }, + { + type: 'row', + fields: [ + { + name: 'restrictedRowText', + type: 'text', + access: { + create: () => false, + read: () => false, + update: () => false, + }, + }, + ], + }, + { + type: 'collapsible', + fields: [ + { + name: 'restrictedCollapsibleText', + type: 'text', + access: { + create: () => false, + read: () => false, + update: () => false, + }, + }, + ], + label: 'Access', + }, + ], + }, + { + slug: unrestrictedSlug, + fields: [ + { + name: 'name', + type: 'text', + }, + { + name: 'info', + type: 'group', + fields: [ + { + name: 'title', + type: 'text', + }, + { + name: 'description', + type: 'textarea', + }, + ], + }, + { + name: 'userRestrictedDocs', + type: 'relationship', + hasMany: true, + relationTo: userRestrictedCollectionSlug, + }, + { + name: 'createNotUpdateDocs', + type: 'relationship', + hasMany: true, + relationTo: createNotUpdateCollectionSlug, + }, + ], + }, + { + slug: 'relation-restricted', + access: { + read: () => true, + }, + fields: [ + { + name: 'name', + type: 'text', + }, + { + name: 'post', + type: 'relationship', + relationTo: slug, + }, + ], + }, + { + slug: fullyRestrictedSlug, + access: { + create: () => false, + delete: () => false, + read: () => false, + update: () => false, + }, + fields: [ + { + name: 'name', + type: 'text', + }, + ], + }, + { + slug: readOnlySlug, + access: { + create: () => false, + delete: () => false, + read: () => true, + update: () => false, + }, + fields: [ + { + name: 'name', + type: 'text', + }, + ], + }, + { + slug: userRestrictedCollectionSlug, + access: { + create: () => true, + delete: () => false, + read: () => true, + update: ({ req }) => ({ + name: { + equals: req.user?.email, + }, + }), + }, + admin: { + useAsTitle: 'name', + }, + fields: [ + { + name: 'name', + type: 'text', + }, + ], + }, + { + slug: createNotUpdateCollectionSlug, + access: { + create: () => true, + delete: () => false, + read: () => true, + update: () => false, + }, + admin: { + useAsTitle: 'name', + }, + fields: [ + { + name: 'name', + type: 'text', + }, + ], + }, + { + slug: restrictedVersionsSlug, + access: { + read: ({ req: { user } }) => { + if (user) { + return true + } + + return { + hidden: { + not_equals: true, + }, + } + }, + readVersions: ({ req: { user } }) => { + if (user) { + return true + } + + return { + 'version.hidden': { + not_equals: true, + }, + } + }, + }, + fields: [ + { + name: 'name', + type: 'text', + }, + { + name: 'hidden', + type: 'checkbox', + hidden: true, + }, + ], + versions: true, + }, + { + slug: restrictedVersionsAdminPanelSlug, + access: { + read: ({ req: { user } }) => { + if (user) { + return true + } + return false + }, + readVersions: () => { + return { + 'version.hidden': { + not_equals: true, + }, + } + }, + }, + fields: [ + { + name: 'name', + type: 'text', + }, + { + name: 'hidden', + type: 'checkbox', + }, + ], + versions: true, + }, + { + slug: siblingDataSlug, + access: openAccess, + fields: [ + { + name: 'array', + type: 'array', + fields: [ + { + type: 'row', + fields: [ + { + name: 'allowPublicReadability', + type: 'checkbox', + label: 'Allow Public Readability', + }, + { + name: 'text', + type: 'text', + access: { + read: PublicReadabilityAccess, + }, + }, + ], + }, + ], + }, + ], + }, + { + slug: relyOnRequestHeadersSlug, + access: { + create: UseRequestHeadersAccess, + delete: UseRequestHeadersAccess, + read: UseRequestHeadersAccess, + update: UseRequestHeadersAccess, + }, + fields: [ + { + name: 'name', + type: 'text', + }, + ], + }, + { + slug: docLevelAccessSlug, + access: { + delete: () => ({ + and: [ + { + approvedForRemoval: { + equals: true, + }, + }, + ], + }), + }, + fields: [ + { + name: 'approvedForRemoval', + type: 'checkbox', + defaultValue: false, + }, + { + name: 'approvedTitle', + type: 'text', + access: { + update: (args) => { + if (args?.doc?.lockTitle) { + return false + } + return true + }, + }, + localized: true, + }, + { + name: 'lockTitle', + type: 'checkbox', + defaultValue: false, + }, + ], + labels: { + plural: 'Doc Level Access', + singular: 'Doc Level Access', + }, + }, + { + slug: hiddenFieldsSlug, + access: openAccess, + fields: [ + { + name: 'title', + type: 'text', + }, + { + name: 'partiallyHiddenGroup', + type: 'group', + fields: [ + { + name: 'name', + type: 'text', + }, + { + name: 'value', + type: 'text', + hidden: true, + }, + ], + }, + { + name: 'partiallyHiddenArray', + type: 'array', + fields: [ + { + name: 'name', + type: 'text', + }, + { + name: 'value', + type: 'text', + hidden: true, + }, + ], + }, + { + name: 'hidden', + type: 'checkbox', + hidden: true, + }, + { + name: 'hiddenWithDefault', + type: 'text', + hidden: true, + defaultValue: 'default value', + }, + ], + }, + { + slug: hiddenAccessSlug, + access: { + read: ({ req: { user } }) => { + if (user) { + return true + } + + return { + hidden: { + not_equals: true, + }, + } + }, + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'hidden', + type: 'checkbox', + hidden: true, + }, + ], + }, + { + slug: hiddenAccessCountSlug, + access: { + read: ({ req: { user } }) => { + if (user) { + return true + } + + return { + hidden: { + not_equals: true, + }, + } + }, + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'hidden', + type: 'checkbox', + hidden: true, + }, + ], + }, + { + slug: 'fields-and-top-access', + access: { + readVersions: () => ({ + 'version.secret': { + equals: 'will-success-access-read', + }, + }), + read: () => ({ + secret: { + equals: 'will-success-access-read', + }, + }), + }, + versions: { drafts: true }, + fields: [ + { + type: 'text', + name: 'secret', + access: { read: () => false }, + }, + ], + }, + BlocksFieldAccess, + Disabled, + RichText, + Regression1, + Regression2, + Hooks, + Auth, + ReadRestricted, + { + slug: 'field-restricted-update-based-on-data', + fields: [ + { + name: 'restricted', + type: 'text', + access: { + update: ({ data }) => { + return !data?.isRestricted + }, + }, + }, + { + name: 'doesNothing', + type: 'checkbox', + }, + { + name: 'isRestricted', + type: 'checkbox', + }, + ], + }, + // Collection for testing where query cache with SAME where queries + { + slug: 'where-cache-same', + access: { + // All operations return the same where query + read: ({ req: { user } }) => { + if (isUser(user) && user.roles?.includes('admin')) { + return { userRole: { equals: 'admin' } } + } + return false + }, + update: ({ req: { user } }) => { + if (isUser(user) && user.roles?.includes('admin')) { + return { userRole: { equals: 'admin' } } + } + return false + }, + delete: ({ req: { user } }) => { + if (isUser(user) && user.roles?.includes('admin')) { + return { userRole: { equals: 'admin' } } + } + return false + }, + create: () => true, + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'userRole', + type: 'text', + required: true, + }, + ], + }, + + // Collection for testing where query cache with UNIQUE where queries + { + slug: 'where-cache-unique', + access: { + // Each operation returns a unique where query + read: ({ req: { user } }) => { + if (isUser(user) && user.roles?.includes('admin')) { + return { readRole: { equals: 'admin' } } + } + return false + }, + update: ({ req: { user } }) => { + if (isUser(user) && user.roles?.includes('admin')) { + return { updateRole: { equals: 'admin' } } + } + return false + }, + delete: ({ req: { user } }) => { + if (isUser(user) && user.roles?.includes('admin')) { + return { deleteRole: { equals: 'admin' } } + } + return false + }, + create: () => true, + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'readRole', + type: 'text', + required: true, + }, + { + name: 'updateRole', + type: 'text', + required: true, + }, + { + name: 'deleteRole', + type: 'text', + required: true, + }, + ], + }, + ], + globals: [ + { + slug: 'settings', + admin: { + components: { + elements: { + SaveButton: '/TestButton.js#TestButton', + }, + }, + }, + fields: [ + { + name: 'test', + type: 'checkbox', + label: 'Allow access to test global', + }, + ], + }, + { + slug: 'test', + access: { + read: async ({ req: { payload } }) => { + const access = await payload.findGlobal({ slug: 'settings' }) + return Boolean(access.test) + }, + }, + fields: [], + }, + { + slug: readOnlyGlobalSlug, + access: { + read: () => true, + update: () => false, + }, + fields: [ + { + name: 'name', + type: 'text', + }, + ], + }, + { + slug: userRestrictedGlobalSlug, + access: { + read: () => true, + update: ({ data, req }) => data?.name === req.user?.email, + }, + fields: [ + { + name: 'name', + type: 'text', + }, + ], + }, + { + slug: readNotUpdateGlobalSlug, + access: { + read: () => true, + update: () => false, + }, + fields: [ + { + name: 'name', + type: 'text', + }, + ], + }, + ], + onInit: async (payload) => { + await payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }) + + await payload.create({ + collection: 'users', + data: { + email: nonAdminEmail, + password: 'test', + }, + }) + + await payload.create({ + collection: publicUsersSlug, + data: { + email: publicUserEmail, + password: 'test', + }, + }) + + await payload.create({ + collection: slug, + data: { + restrictedField: 'restricted', + }, + }) + + await payload.create({ + collection: readOnlySlug, + data: { + name: 'read-only', + }, + }) + + await payload.create({ + collection: blocksFieldAccessSlug, + data: { + title: 'Blocks Field Access Test Document', + editableBlocks: [ + { + blockType: 'testBlock', + title: 'Editable Block', + content: 'This block should be fully editable', + }, + ], + readOnlyBlocks: [ + { + blockType: 'testBlock2', + title: 'Read-Only Block', + content: 'This block should be read-only due to field access control', + }, + ], + editableBlockRefs: [ + { + blockType: 'titleblock', + title: 'Editable Block Reference', + }, + ], + readOnlyBlockRefs: [ + { + blockType: 'titleblock', + title: 'Read-Only Block Reference', + }, + ], + tabReadOnlyTest: { + tabReadOnlyBlocks: [ + { + blockType: 'testBlock3', + title: 'Tab Read-Only Block', + content: 'This block is read-only and inside a tab', + }, + ], + tabReadOnlyBlockRefs: [ + { + blockType: 'titleblock', + title: 'Tab Read-Only Block Reference', + }, + ], + }, + }, + }) + + await payload.create({ + collection: restrictedVersionsSlug, + data: { + name: 'versioned', + }, + }) + + await payload.create({ + collection: siblingDataSlug, + data: { + array: [ + { + allowPublicReadability: true, + text: firstArrayText, + }, + { + allowPublicReadability: false, + text: secondArrayText, + }, + ], + }, + }) + + await payload.updateGlobal({ + slug: userRestrictedGlobalSlug, + data: { + name: 'dev@payloadcms.com', + }, + }) + + await payload.create({ + collection: 'regression1', + data: { + richText4: buildEditorState({ text: 'Text1' }), + array: [{ art: buildEditorState({ text: 'Text2' }) }], + arrayWithAccessFalse: [ + { richText6: buildEditorState({ text: 'Text3' }) }, + ], + group1: { + text: 'Text4', + richText1: buildEditorState({ text: 'Text5' }), + }, + blocks: [ + { + blockType: 'myBlock3', + richText7: buildEditorState({ text: 'Text6' }), + blockName: 'My Block 1', + }, + ], + blocks3: [ + { + blockType: 'myBlock2', + richText5: buildEditorState({ text: 'Text7' }), + blockName: 'My Block 2', + }, + ], + tab1: { + richText2: buildEditorState({ text: 'Text8' }), + blocks2: [ + { + blockType: 'myBlock', + richText3: buildEditorState({ text: 'Text9' }), + blockName: 'My Block 3', + }, + ], + }, + }, + }) + + await payload.create({ + collection: 'regression2', + data: { + array: [ + { + richText2: buildEditorState({ text: 'Text1' }), + }, + ], + group: { + text: 'Text2', + richText1: buildEditorState({ text: 'Text3' }), + }, + }, + }) + + // Seed read-restricted collection + await seedReadRestricted(payload) + }, + typescript: { + outputFile: path.resolve(dirname, 'payload-types.ts'), + }, +}) diff --git a/test/access-control/int.spec.ts b/test/access-control/int.spec.ts index da663417b22..8653347b213 100644 --- a/test/access-control/int.spec.ts +++ b/test/access-control/int.spec.ts @@ -8,13 +8,13 @@ import type { } from 'payload' import path from 'path' -import { Forbidden, ValidationError } from 'payload' +import { Forbidden } from 'payload' import { fileURLToPath } from 'url' import type { FullyRestricted, Post } from './payload-types.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' -import { requestHeaders } from './config.js' +import { requestHeaders } from './getConfig.js' import { firstArrayText, fullyRestrictedSlug, diff --git a/test/access-control/payload-types.ts b/test/access-control/payload-types.ts index ae07fee49b1..c688e2171ec 100644 --- a/test/access-control/payload-types.ts +++ b/test/access-control/payload-types.ts @@ -98,6 +98,8 @@ export interface Config { 'auth-collection': AuthCollection; 'read-restricted': ReadRestricted; 'field-restricted-update-based-on-data': FieldRestrictedUpdateBasedOnDatum; + 'where-cache-same': WhereCacheSame; + 'where-cache-unique': WhereCacheUnique; 'payload-kv': PayloadKv; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; @@ -132,14 +134,17 @@ export interface Config { 'auth-collection': AuthCollectionSelect | AuthCollectionSelect; 'read-restricted': ReadRestrictedSelect | ReadRestrictedSelect; 'field-restricted-update-based-on-data': FieldRestrictedUpdateBasedOnDataSelect | FieldRestrictedUpdateBasedOnDataSelect; + 'where-cache-same': WhereCacheSameSelect | WhereCacheSameSelect; + 'where-cache-unique': WhereCacheUniqueSelect | WhereCacheUniqueSelect; 'payload-kv': PayloadKvSelect | PayloadKvSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { - defaultIDType: number; + defaultIDType: string; }; + fallbackLocale: null; globals: { settings: Setting; test: Test; @@ -239,7 +244,7 @@ export interface Titleblock { * via the `definition` "users". */ export interface User { - id: number; + id: string; roles?: ('admin' | 'user')[] | null; updatedAt: string; createdAt: string; @@ -264,7 +269,7 @@ export interface User { * via the `definition` "public-users". */ export interface PublicUser { - id: number; + id: string; updatedAt: string; createdAt: string; email: string; @@ -288,7 +293,7 @@ export interface PublicUser { * via the `definition` "posts". */ export interface Post { - id: number; + id: string; restrictedField?: string | null; group?: { restrictedGroupText?: string | null; @@ -303,14 +308,14 @@ export interface Post { * via the `definition` "unrestricted". */ export interface Unrestricted { - id: number; + id: string; name?: string | null; info?: { title?: string | null; description?: string | null; }; - userRestrictedDocs?: (number | UserRestrictedCollection)[] | null; - createNotUpdateDocs?: (number | CreateNotUpdateCollection)[] | null; + userRestrictedDocs?: (string | UserRestrictedCollection)[] | null; + createNotUpdateDocs?: (string | CreateNotUpdateCollection)[] | null; updatedAt: string; createdAt: string; } @@ -319,7 +324,7 @@ export interface Unrestricted { * via the `definition` "user-restricted-collection". */ export interface UserRestrictedCollection { - id: number; + id: string; name?: string | null; updatedAt: string; createdAt: string; @@ -329,7 +334,7 @@ export interface UserRestrictedCollection { * via the `definition` "create-not-update-collection". */ export interface CreateNotUpdateCollection { - id: number; + id: string; name?: string | null; updatedAt: string; createdAt: string; @@ -339,9 +344,9 @@ export interface CreateNotUpdateCollection { * via the `definition` "relation-restricted". */ export interface RelationRestricted { - id: number; + id: string; name?: string | null; - post?: (number | null) | Post; + post?: (string | null) | Post; updatedAt: string; createdAt: string; } @@ -350,7 +355,7 @@ export interface RelationRestricted { * via the `definition` "fully-restricted". */ export interface FullyRestricted { - id: number; + id: string; name?: string | null; updatedAt: string; createdAt: string; @@ -360,7 +365,7 @@ export interface FullyRestricted { * via the `definition` "read-only-collection". */ export interface ReadOnlyCollection { - id: number; + id: string; name?: string | null; updatedAt: string; createdAt: string; @@ -370,7 +375,7 @@ export interface ReadOnlyCollection { * via the `definition` "restricted-versions". */ export interface RestrictedVersion { - id: number; + id: string; name?: string | null; hidden?: boolean | null; updatedAt: string; @@ -381,7 +386,7 @@ export interface RestrictedVersion { * via the `definition` "restricted-versions-admin-panel". */ export interface RestrictedVersionsAdminPanel { - id: number; + id: string; name?: string | null; hidden?: boolean | null; updatedAt: string; @@ -392,7 +397,7 @@ export interface RestrictedVersionsAdminPanel { * via the `definition` "sibling-data". */ export interface SiblingDatum { - id: number; + id: string; array?: | { allowPublicReadability?: boolean | null; @@ -408,7 +413,7 @@ export interface SiblingDatum { * via the `definition` "rely-on-request-headers". */ export interface RelyOnRequestHeader { - id: number; + id: string; name?: string | null; updatedAt: string; createdAt: string; @@ -418,7 +423,7 @@ export interface RelyOnRequestHeader { * via the `definition` "doc-level-access". */ export interface DocLevelAccess { - id: number; + id: string; approvedForRemoval?: boolean | null; approvedTitle?: string | null; lockTitle?: boolean | null; @@ -430,7 +435,7 @@ export interface DocLevelAccess { * via the `definition` "hidden-fields". */ export interface HiddenField { - id: number; + id: string; title?: string | null; partiallyHiddenGroup?: { name?: string | null; @@ -453,7 +458,7 @@ export interface HiddenField { * via the `definition` "hidden-access". */ export interface HiddenAccess { - id: number; + id: string; title: string; hidden?: boolean | null; updatedAt: string; @@ -464,7 +469,7 @@ export interface HiddenAccess { * via the `definition` "hidden-access-count". */ export interface HiddenAccessCount { - id: number; + id: string; title: string; hidden?: boolean | null; updatedAt: string; @@ -475,7 +480,7 @@ export interface HiddenAccessCount { * via the `definition` "fields-and-top-access". */ export interface FieldsAndTopAccess { - id: number; + id: string; secret?: string | null; updatedAt: string; createdAt: string; @@ -486,7 +491,7 @@ export interface FieldsAndTopAccess { * via the `definition` "blocks-field-access". */ export interface BlocksFieldAccess { - id: number; + id: string; title: string; editableBlocks?: | { @@ -528,7 +533,7 @@ export interface BlocksFieldAccess { * via the `definition` "disabled". */ export interface Disabled { - id: number; + id: string; group?: { text?: string | null; }; @@ -550,7 +555,7 @@ export interface Disabled { * via the `definition` "rich-text". */ export interface RichText { - id: number; + id: string; blocks?: | { richText?: { @@ -581,7 +586,7 @@ export interface RichText { * via the `definition` "regression1". */ export interface Regression1 { - id: number; + id: string; group1?: { richText1?: { root: { @@ -746,7 +751,7 @@ export interface Regression1 { * via the `definition` "regression2". */ export interface Regression2 { - id: number; + id: string; group?: { richText1?: { root: { @@ -793,7 +798,7 @@ export interface Regression2 { * via the `definition` "hooks". */ export interface Hook { - id: number; + id: string; cannotMutateRequired: string; cannotMutateNotRequired?: string | null; canMutate?: string | null; @@ -805,7 +810,7 @@ export interface Hook { * via the `definition` "auth-collection". */ export interface AuthCollection { - id: number; + id: string; password?: string | null; roles?: ('admin' | 'user')[] | null; updatedAt: string; @@ -832,7 +837,7 @@ export interface AuthCollection { * via the `definition` "read-restricted". */ export interface ReadRestricted { - id: number; + id: string; restrictedTopLevel?: string | null; visibleTopLevel?: string | null; contactInfo?: { @@ -875,7 +880,7 @@ export interface ReadRestricted { visibleAdvanced?: string | null; restrictedAdvanced?: string | null; }; - unrestricted?: (number | null) | Unrestricted; + unrestricted?: (string | null) | Unrestricted; unrestrictedVirtualFieldName?: string | null; unrestrictedVirtualGroupInfo?: { title?: string | null; @@ -890,19 +895,43 @@ export interface ReadRestricted { * via the `definition` "field-restricted-update-based-on-data". */ export interface FieldRestrictedUpdateBasedOnDatum { - id: number; + id: string; restricted?: string | null; doesNothing?: boolean | null; isRestricted?: boolean | null; updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "where-cache-same". + */ +export interface WhereCacheSame { + id: string; + title: string; + userRole: string; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "where-cache-unique". + */ +export interface WhereCacheUnique { + id: string; + title: string; + readRole: string; + updateRole: string; + deleteRole: string; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-kv". */ export interface PayloadKv { - id: number; + id: string; key: string; data: | { @@ -919,129 +948,137 @@ export interface PayloadKv { * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: number; + id: string; document?: | ({ relationTo: 'users'; - value: number | User; + value: string | User; } | null) | ({ relationTo: 'public-users'; - value: number | PublicUser; + value: string | PublicUser; } | null) | ({ relationTo: 'posts'; - value: number | Post; + value: string | Post; } | null) | ({ relationTo: 'unrestricted'; - value: number | Unrestricted; + value: string | Unrestricted; } | null) | ({ relationTo: 'relation-restricted'; - value: number | RelationRestricted; + value: string | RelationRestricted; } | null) | ({ relationTo: 'fully-restricted'; - value: number | FullyRestricted; + value: string | FullyRestricted; } | null) | ({ relationTo: 'read-only-collection'; - value: number | ReadOnlyCollection; + value: string | ReadOnlyCollection; } | null) | ({ relationTo: 'user-restricted-collection'; - value: number | UserRestrictedCollection; + value: string | UserRestrictedCollection; } | null) | ({ relationTo: 'create-not-update-collection'; - value: number | CreateNotUpdateCollection; + value: string | CreateNotUpdateCollection; } | null) | ({ relationTo: 'restricted-versions'; - value: number | RestrictedVersion; + value: string | RestrictedVersion; } | null) | ({ relationTo: 'restricted-versions-admin-panel'; - value: number | RestrictedVersionsAdminPanel; + value: string | RestrictedVersionsAdminPanel; } | null) | ({ relationTo: 'sibling-data'; - value: number | SiblingDatum; + value: string | SiblingDatum; } | null) | ({ relationTo: 'rely-on-request-headers'; - value: number | RelyOnRequestHeader; + value: string | RelyOnRequestHeader; } | null) | ({ relationTo: 'doc-level-access'; - value: number | DocLevelAccess; + value: string | DocLevelAccess; } | null) | ({ relationTo: 'hidden-fields'; - value: number | HiddenField; + value: string | HiddenField; } | null) | ({ relationTo: 'hidden-access'; - value: number | HiddenAccess; + value: string | HiddenAccess; } | null) | ({ relationTo: 'hidden-access-count'; - value: number | HiddenAccessCount; + value: string | HiddenAccessCount; } | null) | ({ relationTo: 'fields-and-top-access'; - value: number | FieldsAndTopAccess; + value: string | FieldsAndTopAccess; } | null) | ({ relationTo: 'blocks-field-access'; - value: number | BlocksFieldAccess; + value: string | BlocksFieldAccess; } | null) | ({ relationTo: 'disabled'; - value: number | Disabled; + value: string | Disabled; } | null) | ({ relationTo: 'rich-text'; - value: number | RichText; + value: string | RichText; } | null) | ({ relationTo: 'regression1'; - value: number | Regression1; + value: string | Regression1; } | null) | ({ relationTo: 'regression2'; - value: number | Regression2; + value: string | Regression2; } | null) | ({ relationTo: 'hooks'; - value: number | Hook; + value: string | Hook; } | null) | ({ relationTo: 'auth-collection'; - value: number | AuthCollection; + value: string | AuthCollection; } | null) | ({ relationTo: 'read-restricted'; - value: number | ReadRestricted; + value: string | ReadRestricted; } | null) | ({ relationTo: 'field-restricted-update-based-on-data'; - value: number | FieldRestrictedUpdateBasedOnDatum; + value: string | FieldRestrictedUpdateBasedOnDatum; + } | null) + | ({ + relationTo: 'where-cache-same'; + value: string | WhereCacheSame; + } | null) + | ({ + relationTo: 'where-cache-unique'; + value: string | WhereCacheUnique; } | null); globalSlug?: string | null; user: | { relationTo: 'users'; - value: number | User; + value: string | User; } | { relationTo: 'public-users'; - value: number | PublicUser; + value: string | PublicUser; } | { relationTo: 'auth-collection'; - value: number | AuthCollection; + value: string | AuthCollection; }; updatedAt: string; createdAt: string; @@ -1051,19 +1088,19 @@ export interface PayloadLockedDocument { * via the `definition` "payload-preferences". */ export interface PayloadPreference { - id: number; + id: string; user: | { relationTo: 'users'; - value: number | User; + value: string | User; } | { relationTo: 'public-users'; - value: number | PublicUser; + value: string | PublicUser; } | { relationTo: 'auth-collection'; - value: number | AuthCollection; + value: string | AuthCollection; }; key?: string | null; value?: @@ -1083,7 +1120,7 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: number; + id: string; name?: string | null; batch?: number | null; updatedAt: string; @@ -1622,6 +1659,28 @@ export interface FieldRestrictedUpdateBasedOnDataSelect { + title?: T; + userRole?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "where-cache-unique_select". + */ +export interface WhereCacheUniqueSelect { + title?: T; + readRole?: T; + updateRole?: T; + deleteRole?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-kv_select". @@ -1667,7 +1726,7 @@ export interface PayloadMigrationsSelect { * via the `definition` "settings". */ export interface Setting { - id: number; + id: string; test?: boolean | null; updatedAt?: string | null; createdAt?: string | null; @@ -1677,7 +1736,7 @@ export interface Setting { * via the `definition` "test". */ export interface Test { - id: number; + id: string; updatedAt?: string | null; createdAt?: string | null; } @@ -1686,7 +1745,7 @@ export interface Test { * via the `definition` "read-only-global". */ export interface ReadOnlyGlobal { - id: number; + id: string; name?: string | null; updatedAt?: string | null; createdAt?: string | null; @@ -1696,7 +1755,7 @@ export interface ReadOnlyGlobal { * via the `definition` "user-restricted-global". */ export interface UserRestrictedGlobal { - id: number; + id: string; name?: string | null; updatedAt?: string | null; createdAt?: string | null; @@ -1706,7 +1765,7 @@ export interface UserRestrictedGlobal { * via the `definition` "read-not-update-global". */ export interface ReadNotUpdateGlobal { - id: number; + id: string; name?: string | null; updatedAt?: string | null; createdAt?: string | null; diff --git a/test/access-control/postgres-logs.int.spec.ts b/test/access-control/postgres-logs.int.spec.ts new file mode 100644 index 00000000000..1961686028d --- /dev/null +++ b/test/access-control/postgres-logs.int.spec.ts @@ -0,0 +1,264 @@ +import type { CollectionPermission, Payload, PayloadRequest } from 'payload' + +/* eslint-disable jest/require-top-level-describe */ +import assert from 'assert' +import path from 'path' +import { createLocalReq, getEntityPermissions } from 'payload' +import { fileURLToPath } from 'url' + +import { initPayloadInt } from '../helpers/initPayloadInt.js' +import { whereCacheSameSlug, whereCacheUniqueSlug } from './shared.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +const describePostgres = process.env.PAYLOAD_DATABASE?.startsWith('postgres') + ? describe + : describe.skip + +let payload: Payload +let req: PayloadRequest + +describePostgres('Access Control - postgres logs', () => { + beforeAll(async () => { + const initialized = await initPayloadInt( + dirname, + undefined, + undefined, + 'config.postgreslogs.ts', + ) + assert(initialized.payload) + assert(initialized.restClient) + ;({ payload } = initialized) + + req = await createLocalReq( + { + user: { + id: 123 as any, + collection: 'users', + roles: ['admin'], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + email: 'test@test.com', + }, + }, + payload, + ) + }) + + afterAll(async () => { + if (payload) { + await payload.destroy() + } + }) + + describe('Tests', () => { + describe('where query cache - same where queries', () => { + it('should cache identical where queries across operations, without passing data (2 DB calls total)', async () => { + const doc = await payload.create({ + collection: whereCacheSameSlug, + data: { + title: 'Test Document', + userRole: 'admin', + }, + }) + + const consoleCount = jest.spyOn(console, 'log').mockImplementation(() => {}) + + // Get permissions - all operations return same where query + const permissions = await getEntityPermissions({ + id: doc.id, + blockReferencesPermissions: {} as any, + entity: payload.collections[whereCacheSameSlug].config, + entityType: 'collection', + operations: ['read', 'update', 'delete'], + fetchData: true, + req, + }) + + // 1 db call across all operations due to cache, + 1 for the document fetch + expect(consoleCount).toHaveBeenCalledTimes(2) + + consoleCount.mockRestore() + + expect(permissions).toEqual({ + fields: { + title: { read: { permission: true }, update: { permission: true } }, + userRole: { read: { permission: true }, update: { permission: true } }, + updatedAt: { read: { permission: true }, update: { permission: true } }, + createdAt: { read: { permission: true }, update: { permission: true } }, + }, + read: { permission: true, where: { userRole: { equals: 'admin' } } }, + update: { permission: true, where: { userRole: { equals: 'admin' } } }, + delete: { permission: true, where: { userRole: { equals: 'admin' } } }, + } satisfies CollectionPermission) + }) + + it('should cache identical where queries across operations, with passing data (1 DB call total)', async () => { + const doc = await payload.create({ + collection: whereCacheSameSlug, + data: { + title: 'Test Document', + userRole: 'noAccess', + }, + }) + + const consoleCount = jest.spyOn(console, 'log').mockImplementation(() => {}) + + // Get permissions - all operations return same where query + const permissions = await getEntityPermissions({ + id: doc.id, + blockReferencesPermissions: {} as any, + entity: payload.collections[whereCacheSameSlug].config, + entityType: 'collection', + operations: ['read', 'update', 'delete'], + fetchData: true, + req, + data: doc, + }) + + // 1 db call across all operations due to cache + expect(consoleCount).toHaveBeenCalledTimes(1) + + consoleCount.mockRestore() + + expect(permissions).toEqual({ + fields: { + title: { read: { permission: false }, update: { permission: false } }, + userRole: { read: { permission: false }, update: { permission: false } }, + updatedAt: { read: { permission: false }, update: { permission: false } }, + createdAt: { read: { permission: false }, update: { permission: false } }, + }, + read: { permission: false, where: { userRole: { equals: 'admin' } } }, + update: { permission: false, where: { userRole: { equals: 'admin' } } }, + delete: { permission: false, where: { userRole: { equals: 'admin' } } }, + } satisfies CollectionPermission) + }) + }) + + describe('where query cache - unique where queries', () => { + it('should handle unique where queries per operation (1 DB call per operation)', async () => { + const doc = await payload.create({ + collection: whereCacheUniqueSlug, + data: { + title: 'Test Document', + readRole: 'admin', + updateRole: 'noAccess', + deleteRole: 'admin', + }, + }) + + const consoleCount = jest.spyOn(console, 'log').mockImplementation(() => {}) + + // Get permissions - each operation returns unique where query + const permissions = await getEntityPermissions({ + id: doc.id, + blockReferencesPermissions: {} as any, + entity: payload.collections[whereCacheUniqueSlug].config, + entityType: 'collection', + operations: ['read', 'update', 'delete'], + fetchData: true, + req, + }) + + // 3 access control operations with unique where + 1 for the document fetch, since we're not passing data. + expect(consoleCount).toHaveBeenCalledTimes(4) + consoleCount.mockRestore() + + expect(permissions).toEqual({ + fields: { + title: { read: { permission: true }, update: { permission: false } }, + readRole: { read: { permission: true }, update: { permission: false } }, + updateRole: { read: { permission: true }, update: { permission: false } }, + deleteRole: { read: { permission: true }, update: { permission: false } }, + updatedAt: { read: { permission: true }, update: { permission: false } }, + createdAt: { read: { permission: true }, update: { permission: false } }, + }, + read: { permission: true, where: { readRole: { equals: 'admin' } } }, + update: { permission: false, where: { updateRole: { equals: 'admin' } } }, + delete: { permission: true, where: { deleteRole: { equals: 'admin' } } }, + } satisfies CollectionPermission) + }) + + it('should handle unique where queries per operation (1 DB call per operation), no data fetch when passing data', async () => { + const doc = await payload.create({ + collection: whereCacheUniqueSlug, + data: { + title: 'Test Document', + readRole: 'admin', + updateRole: 'noAccess', + deleteRole: 'admin', + }, + }) + + const consoleCount = jest.spyOn(console, 'log').mockImplementation(() => {}) + + // Get permissions - each operation returns unique where query + const permissions = await getEntityPermissions({ + id: doc.id, + blockReferencesPermissions: {} as any, + entity: payload.collections[whereCacheUniqueSlug].config, + entityType: 'collection', + operations: ['read', 'update', 'delete'], + fetchData: true, + req, + data: doc, + }) + + // 3 access control operations with unique where, no data fetch since we're passing data + expect(consoleCount).toHaveBeenCalledTimes(3) + consoleCount.mockRestore() + + expect(permissions).toEqual({ + fields: { + title: { read: { permission: true }, update: { permission: false } }, + readRole: { read: { permission: true }, update: { permission: false } }, + updateRole: { read: { permission: true }, update: { permission: false } }, + deleteRole: { read: { permission: true }, update: { permission: false } }, + updatedAt: { read: { permission: true }, update: { permission: false } }, + createdAt: { read: { permission: true }, update: { permission: false } }, + }, + read: { permission: true, where: { readRole: { equals: 'admin' } } }, + update: { permission: false, where: { updateRole: { equals: 'admin' } } }, + delete: { permission: true, where: { deleteRole: { equals: 'admin' } } }, + } satisfies CollectionPermission) + }) + + it('should return correct permissions with mixed results', async () => { + const doc = await payload.create({ + collection: whereCacheUniqueSlug, + data: { + title: 'Test Document 2', + readRole: 'noAccess', + updateRole: 'admin', + deleteRole: 'noAccess', + }, + }) + + const permissions = await getEntityPermissions({ + id: doc.id, + blockReferencesPermissions: {} as any, + entity: payload.collections[whereCacheUniqueSlug].config, + entityType: 'collection', + operations: ['read', 'update', 'delete'], + fetchData: true, + req, + }) + + expect(permissions).toEqual({ + fields: { + title: { read: { permission: false }, update: { permission: true } }, + readRole: { read: { permission: false }, update: { permission: true } }, + updateRole: { read: { permission: false }, update: { permission: true } }, + deleteRole: { read: { permission: false }, update: { permission: true } }, + updatedAt: { read: { permission: false }, update: { permission: true } }, + createdAt: { read: { permission: false }, update: { permission: true } }, + }, + read: { permission: false, where: { readRole: { equals: 'admin' } } }, + delete: { permission: false, where: { deleteRole: { equals: 'admin' } } }, + update: { permission: true, where: { updateRole: { equals: 'admin' } } }, + } satisfies CollectionPermission) + }) + }) + }) +}) diff --git a/test/access-control/shared.ts b/test/access-control/shared.ts index 2fff587bb61..e06fb8f4ba9 100644 --- a/test/access-control/shared.ts +++ b/test/access-control/shared.ts @@ -28,3 +28,6 @@ export const publicUserEmail = 'public-user@payloadcms.com' export const publicUsersSlug = 'public-users' export const authSlug = 'auth-collection' + +export const whereCacheSameSlug = 'where-cache-same' +export const whereCacheUniqueSlug = 'where-cache-unique' diff --git a/test/auth/payload-types.ts b/test/auth/payload-types.ts index f75bf9f4a45..0530dd7e484 100644 --- a/test/auth/payload-types.ts +++ b/test/auth/payload-types.ts @@ -79,6 +79,7 @@ export interface Config { 'public-users': PublicUser; relationsCollection: RelationsCollection; 'api-keys-with-field-read-access': ApiKeysWithFieldReadAccess; + 'payload-kv': PayloadKv; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; @@ -92,6 +93,7 @@ export interface Config { 'public-users': PublicUsersSelect | PublicUsersSelect; relationsCollection: RelationsCollectionSelect | RelationsCollectionSelect; 'api-keys-with-field-read-access': ApiKeysWithFieldReadAccessSelect | ApiKeysWithFieldReadAccessSelect; + 'payload-kv': PayloadKvSelect | PayloadKvSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -392,6 +394,23 @@ export interface ApiKeysWithFieldReadAccess { apiKey?: string | null; apiKeyIndex?: string | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-kv". + */ +export interface PayloadKv { + id: string; + key: string; + data: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents". @@ -653,6 +672,14 @@ export interface ApiKeysWithFieldReadAccessSelect { apiKey?: T; apiKeyIndex?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-kv_select". + */ +export interface PayloadKvSelect { + key?: T; + data?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents_select". From 89375f639bed031c38ca41e000994c66363a76ea Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Tue, 18 Nov 2025 19:44:56 -0800 Subject: [PATCH 36/42] add another test --- packages/payload/src/index.ts | 1 + .../src/utilities/sanitizePermissions.ts | 2 + test/access-control/postgres-logs.int.spec.ts | 43 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index dfd1f17e9e1..6b3ea02d608 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1760,6 +1760,7 @@ export { mergeHeaders } from './utilities/mergeHeaders.js' export { parseDocumentID } from './utilities/parseDocumentID.js' export { sanitizeFallbackLocale } from './utilities/sanitizeFallbackLocale.js' export { sanitizeJoinParams } from './utilities/sanitizeJoinParams.js' +export { sanitizePermissions } from './utilities/sanitizePermissions.js' export { sanitizePopulateParam } from './utilities/sanitizePopulateParam.js' export { sanitizeSelectParam } from './utilities/sanitizeSelectParam.js' export { stripUnselectedFields } from './utilities/stripUnselectedFields.js' diff --git a/packages/payload/src/utilities/sanitizePermissions.ts b/packages/payload/src/utilities/sanitizePermissions.ts index c35e2c69514..6e61263de3d 100644 --- a/packages/payload/src/utilities/sanitizePermissions.ts +++ b/packages/payload/src/utilities/sanitizePermissions.ts @@ -202,6 +202,8 @@ export function recursivelySanitizeGlobals(obj: Permissions['globals']): void { /** * Recursively remove empty objects and false values from an object. + * + * @internal - this function may change or be removed in a minor release. */ export function sanitizePermissions( data: MarkOptional, diff --git a/test/access-control/postgres-logs.int.spec.ts b/test/access-control/postgres-logs.int.spec.ts index 1961686028d..f749a2c7c2d 100644 --- a/test/access-control/postgres-logs.int.spec.ts +++ b/test/access-control/postgres-logs.int.spec.ts @@ -259,6 +259,49 @@ describePostgres('Access Control - postgres logs', () => { update: { permission: true, where: { updateRole: { equals: 'admin' } } }, } satisfies CollectionPermission) }) + + it('ensure no db calls when fetchData is false', async () => { + const _doc = await payload.create({ + collection: whereCacheUniqueSlug, + data: { + title: 'Test Document', + readRole: 'admin', + updateRole: 'noAccess', + deleteRole: 'admin', + }, + }) + + const consoleCount = jest.spyOn(console, 'log').mockImplementation(() => {}) + + // Get permissions - each operation returns unique where query + const permissions = await getEntityPermissions({ + blockReferencesPermissions: {} as any, + entity: payload.collections[whereCacheUniqueSlug].config, + entityType: 'collection', + operations: ['read', 'update', 'delete'], + fetchData: false, + req, + }) + + expect(consoleCount).toHaveBeenCalledTimes(0) + consoleCount.mockRestore() + + expect(permissions).toEqual({ + // TODO: Permissions currently default to true when fetchData is false, this should be changed to false in 4.0. + // These are later sanitized to false in the sanitizePermissions function. + fields: { + title: { read: { permission: true }, update: { permission: true } }, + readRole: { read: { permission: true }, update: { permission: true } }, + updateRole: { read: { permission: true }, update: { permission: true } }, + deleteRole: { read: { permission: true }, update: { permission: true } }, + updatedAt: { read: { permission: true }, update: { permission: true } }, + createdAt: { read: { permission: true }, update: { permission: true } }, + }, + read: { permission: true, where: { readRole: { equals: 'admin' } } }, + update: { permission: true, where: { updateRole: { equals: 'admin' } } }, + delete: { permission: true, where: { deleteRole: { equals: 'admin' } } }, + } satisfies CollectionPermission) + }) }) }) }) From 9395c3abc40a2406b5aa9020728e3898b7eaa250 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Tue, 18 Nov 2025 19:49:30 -0800 Subject: [PATCH 37/42] more accurate comments --- .../src/utilities/getEntityPermissions/getEntityPermissions.ts | 1 + test/access-control/postgres-logs.int.spec.ts | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts index 7a0840f147b..684f77f55ba 100644 --- a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -298,6 +298,7 @@ const processWhereQuery = ({ // TODO: 4.0: Investigate defaulting to `false` here, if where query is returned but ignored as we don't // have the document data available. This seems more secure. // Alternatively, we could set permission to a third state, like 'unknown'. + // Even after calling sanitizePermissions, the permissions will still be true if the where query is returned but ignored as we don't have the document data available. entityPermissions[operation] = { permission: true, where: accessResult } as Permission } } diff --git a/test/access-control/postgres-logs.int.spec.ts b/test/access-control/postgres-logs.int.spec.ts index f749a2c7c2d..42c21ce0d95 100644 --- a/test/access-control/postgres-logs.int.spec.ts +++ b/test/access-control/postgres-logs.int.spec.ts @@ -288,7 +288,6 @@ describePostgres('Access Control - postgres logs', () => { expect(permissions).toEqual({ // TODO: Permissions currently default to true when fetchData is false, this should be changed to false in 4.0. - // These are later sanitized to false in the sanitizePermissions function. fields: { title: { read: { permission: true }, update: { permission: true } }, readRole: { read: { permission: true }, update: { permission: true } }, From f8eef3dbe7204a527a188bb01f0073d65d68d0bc Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Tue, 18 Nov 2025 23:22:16 -0800 Subject: [PATCH 38/42] fix: some field permission promises depending on parent promises were not awaited, leading in error when sanitizePermissions encounters promise --- .../getEntityPermissions/populateFieldPermissions.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts index 6668d7ae2a6..5d3eb50a8c6 100644 --- a/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts @@ -29,13 +29,16 @@ const setPermission = ( promises: Promise[], ): void => { if (isThenable(value)) { + // Create a single permission object that will be mutated in place + // This ensures all references (including cached blocks) see the resolved value + const permissionObj = { permission: value as any } + target[operation] = permissionObj + const permissionPromise = value.then((result) => { - target[operation] = { permission: result } + // Mutate the permission property in place so all references see the update + permissionObj.permission = result }) - // Store promise temporarily, so that children can access the permission before it is resolved. - // It will be overwritten when promise resolves - target[operation] = { permission: permissionPromise as unknown as boolean } promises.push(permissionPromise) } else { target[operation] = { permission: value } From 8ef54c7ae585d9698129e642278b8be0dd4af2dd Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Wed, 19 Nov 2025 00:00:03 -0800 Subject: [PATCH 39/42] test: add test ensuring fields-parent permission inheritance works --- test/access-control/getConfig.ts | 56 +++++++++++ test/access-control/int.spec.ts | 138 ++++++++++++++++++++++++++- test/access-control/payload-types.ts | 45 +++++++++ test/access-control/shared.ts | 1 + 4 files changed, 239 insertions(+), 1 deletion(-) diff --git a/test/access-control/getConfig.ts b/test/access-control/getConfig.ts index c4c6668e48e..32f3b83dce4 100644 --- a/test/access-control/getConfig.ts +++ b/test/access-control/getConfig.ts @@ -731,6 +731,62 @@ export const getConfig: () => Partial = () => ({ }, ], }, + // Collection for testing async parent permission inheritance + { + slug: 'async-parent', + access: openAccess, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'parentField', + type: 'group', + access: { + read: async ({ req: { user } }) => { + // Simulate async permission check + await new Promise((resolve) => setTimeout(resolve, 1)) + return Boolean(isUser(user) && user.roles?.includes('admin')) + }, + update: async ({ req: { user } }) => { + await new Promise((resolve) => setTimeout(resolve, 1)) + return Boolean(isUser(user) && user.roles?.includes('admin')) + }, + }, + fields: [ + { + name: 'childField1', + type: 'text', + // No access control - should inherit from parent + }, + { + name: 'childField2', + type: 'textarea', + // No access control - should inherit from parent + }, + { + name: 'nestedGroup', + type: 'group', + // No access control - should inherit from parent + fields: [ + { + name: 'deepChild1', + type: 'text', + // No access control - should inherit from grandparent + }, + { + name: 'deepChild2', + type: 'number', + // No access control - should inherit from grandparent + }, + ], + }, + ], + }, + ], + }, ], globals: [ { diff --git a/test/access-control/int.spec.ts b/test/access-control/int.spec.ts index 8653347b213..4c9b1fd42cc 100644 --- a/test/access-control/int.spec.ts +++ b/test/access-control/int.spec.ts @@ -1,5 +1,6 @@ import type { NextRESTClient } from 'helpers/NextRESTClient.js' import type { + CollectionPermission, CollectionSlug, DataFromCollectionSlug, Payload, @@ -8,7 +9,7 @@ import type { } from 'payload' import path from 'path' -import { Forbidden } from 'payload' +import { createLocalReq, Forbidden, getEntityPermissions } from 'payload' import { fileURLToPath } from 'url' import type { FullyRestricted, Post } from './payload-types.js' @@ -16,6 +17,7 @@ import type { FullyRestricted, Post } from './payload-types.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' import { requestHeaders } from './getConfig.js' import { + asyncParentSlug, firstArrayText, fullyRestrictedSlug, hiddenAccessCountSlug, @@ -727,6 +729,140 @@ describe('Access Control', () => { ).rejects.toThrow('Token is either invalid or has expired.') }) }) + + describe('async parent permission inheritance', () => { + it('should inherit async parent field permissions to nested children', async () => { + const doc = await payload.create({ + collection: asyncParentSlug, + data: { + title: 'Test Document', + }, + }) + + const req = await createLocalReq( + { + user: { + id: 123 as any, + collection: 'users', + roles: ['admin'], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + email: 'test@test.com', + }, + }, + payload, + ) + + // Get permissions with admin user (should have access) + const permissions = await getEntityPermissions({ + id: doc.id, + blockReferencesPermissions: {} as any, + entity: payload.collections[asyncParentSlug].config, + entityType: 'collection', + operations: ['read', 'update'], + fetchData: true, + req, + }) + + expect(permissions).toEqual({ + fields: { + title: { read: { permission: true }, update: { permission: true } }, + parentField: { + read: { permission: true }, + update: { permission: true }, + fields: { + childField1: { read: { permission: true }, update: { permission: true } }, + childField2: { read: { permission: true }, update: { permission: true } }, + nestedGroup: { + read: { permission: true }, + update: { permission: true }, + fields: { + deepChild1: { + read: { permission: true }, + update: { permission: true }, + }, + deepChild2: { + read: { permission: true }, + update: { permission: true }, + }, + }, + }, + }, + }, + updatedAt: { read: { permission: true }, update: { permission: true } }, + createdAt: { read: { permission: true }, update: { permission: true } }, + }, + read: { permission: true }, + update: { permission: true }, + } satisfies CollectionPermission) + }) + + it('should correctly deny access when async parent denies (non-admin user)', async () => { + const doc = await payload.create({ + collection: asyncParentSlug, + data: { + title: 'Test Document 2', + }, + }) + + // Create non-admin user request + const nonAdminReq = await createLocalReq( + { + user: { + id: 456 as any, + collection: 'users', + roles: ['user'], // Not admin + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + email: 'user@test.com', + }, + }, + payload, + ) + + const permissions = await getEntityPermissions({ + id: doc.id, + blockReferencesPermissions: {} as any, + entity: payload.collections[asyncParentSlug].config, + entityType: 'collection', + operations: ['read', 'update'], + fetchData: true, + req: nonAdminReq, + }) + + expect(permissions).toEqual({ + fields: { + title: { read: { permission: true }, update: { permission: true } }, + parentField: { + read: { permission: false }, + update: { permission: false }, + fields: { + childField1: { read: { permission: false }, update: { permission: false } }, + childField2: { read: { permission: false }, update: { permission: false } }, + nestedGroup: { + read: { permission: false }, + update: { permission: false }, + fields: { + deepChild1: { + read: { permission: false }, + update: { permission: false }, + }, + deepChild2: { + read: { permission: false }, + update: { permission: false }, + }, + }, + }, + }, + }, + updatedAt: { read: { permission: true }, update: { permission: true } }, + createdAt: { read: { permission: true }, update: { permission: true } }, + }, + read: { permission: true }, + update: { permission: true }, + } satisfies CollectionPermission) + }) + }) }) async function createDoc( diff --git a/test/access-control/payload-types.ts b/test/access-control/payload-types.ts index c688e2171ec..7dc0731c903 100644 --- a/test/access-control/payload-types.ts +++ b/test/access-control/payload-types.ts @@ -100,6 +100,7 @@ export interface Config { 'field-restricted-update-based-on-data': FieldRestrictedUpdateBasedOnDatum; 'where-cache-same': WhereCacheSame; 'where-cache-unique': WhereCacheUnique; + 'async-parent': AsyncParent; 'payload-kv': PayloadKv; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; @@ -136,6 +137,7 @@ export interface Config { 'field-restricted-update-based-on-data': FieldRestrictedUpdateBasedOnDataSelect | FieldRestrictedUpdateBasedOnDataSelect; 'where-cache-same': WhereCacheSameSelect | WhereCacheSameSelect; 'where-cache-unique': WhereCacheUniqueSelect | WhereCacheUniqueSelect; + 'async-parent': AsyncParentSelect | AsyncParentSelect; 'payload-kv': PayloadKvSelect | PayloadKvSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; @@ -926,6 +928,24 @@ export interface WhereCacheUnique { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "async-parent". + */ +export interface AsyncParent { + id: string; + title: string; + parentField?: { + childField1?: string | null; + childField2?: string | null; + nestedGroup?: { + deepChild1?: string | null; + deepChild2?: number | null; + }; + }; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-kv". @@ -1065,6 +1085,10 @@ export interface PayloadLockedDocument { | ({ relationTo: 'where-cache-unique'; value: string | WhereCacheUnique; + } | null) + | ({ + relationTo: 'async-parent'; + value: string | AsyncParent; } | null); globalSlug?: string | null; user: @@ -1681,6 +1705,27 @@ export interface WhereCacheUniqueSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "async-parent_select". + */ +export interface AsyncParentSelect { + title?: T; + parentField?: + | T + | { + childField1?: T; + childField2?: T; + nestedGroup?: + | T + | { + deepChild1?: T; + deepChild2?: T; + }; + }; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-kv_select". diff --git a/test/access-control/shared.ts b/test/access-control/shared.ts index e06fb8f4ba9..e860ebfc64f 100644 --- a/test/access-control/shared.ts +++ b/test/access-control/shared.ts @@ -31,3 +31,4 @@ export const authSlug = 'auth-collection' export const whereCacheSameSlug = 'where-cache-same' export const whereCacheUniqueSlug = 'where-cache-unique' +export const asyncParentSlug = 'async-parent' From d8c30856d6407934b47e27f60471413690c927c7 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Wed, 19 Nov 2025 00:08:40 -0800 Subject: [PATCH 40/42] delete getEntityPolicies --- packages/payload/src/index.ts | 1 - .../src/utilities/getEntityPolicies.ts | 392 ------------------ 2 files changed, 393 deletions(-) delete mode 100644 packages/payload/src/utilities/getEntityPolicies.ts diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 6b3ea02d608..c6fcd1c3e37 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1741,7 +1741,6 @@ export { formatLabels, formatNames, toWords } from './utilities/formatLabels.js' export { getBlockSelect } from './utilities/getBlockSelect.js' export { getCollectionIDFieldTypes } from './utilities/getCollectionIDFieldTypes.js' export { getEntityPermissions } from './utilities/getEntityPermissions/getEntityPermissions.js' -export { getEntityPolicies } from './utilities/getEntityPolicies.js' export { getFieldByPath } from './utilities/getFieldByPath.js' export { getObjectDotNotation } from './utilities/getObjectDotNotation.js' export { getRequestLanguage } from './utilities/getRequestLanguage.js' diff --git a/packages/payload/src/utilities/getEntityPolicies.ts b/packages/payload/src/utilities/getEntityPolicies.ts deleted file mode 100644 index 3b2b95fd9ec..00000000000 --- a/packages/payload/src/utilities/getEntityPolicies.ts +++ /dev/null @@ -1,392 +0,0 @@ -import type { CollectionPermission, FieldsPermissions, GlobalPermission } from '../auth/types.js' -import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js' -import type { Access } from '../config/types.js' -import type { Field, FieldAccess } from '../fields/config/types.js' -import type { SanitizedGlobalConfig } from '../globals/config/types.js' -import type { BlockSlug } from '../index.js' -import type { AllOperations, JsonObject, Payload, PayloadRequest, Where } from '../types/index.js' - -import { combineQueries } from '../database/combineQueries.js' -import { tabHasName } from '../fields/config/types.js' - -export type BlockPolicies = Record> -type Args = { - blockPolicies: BlockPolicies - entity: SanitizedCollectionConfig | SanitizedGlobalConfig - id?: number | string - operations: AllOperations[] - req: PayloadRequest - type: 'collection' | 'global' -} - -type ReturnType = T['type'] extends 'global' - ? GlobalPermission - : CollectionPermission - -type CreateAccessPromise = (args: { - access: Access | FieldAccess - accessLevel: 'entity' | 'field' - disableWhere?: boolean - operation: AllOperations - policiesObj: CollectionPermission | GlobalPermission -}) => Promise - -type EntityDoc = JsonObject | TypeWithID - -/** - * Build up permissions object for an entity (collection or global). SCHEMA Permissions - disregards siblingData - */ -export async function getEntityPolicies(args: T): Promise> { - const { id, type, blockPolicies, entity, operations, req } = args - const { data, locale, payload, user } = req - const isLoggedIn = !!user - - const policies = { - fields: {}, - } as ReturnType - - let docBeingAccessed: EntityDoc | Promise | undefined - - async function getEntityDoc({ - operation, - where, - }: { operation?: AllOperations; where?: Where } = {}): Promise { - if (!entity.slug) { - return undefined - } - - if (type === 'global') { - return payload.findGlobal({ - slug: entity.slug, - depth: 0, - fallbackLocale: null, - locale, - overrideAccess: true, - req, - }) - } - - if (type === 'collection' && id) { - if (typeof where === 'object') { - const options = { - collection: entity.slug, - depth: 0, - fallbackLocale: null, - limit: 1, - locale, - overrideAccess: true, - req, - } - - if (operation === 'readVersions') { - const paginatedRes = await payload.findVersions({ - ...options, - where: combineQueries(where, { parent: { equals: id } }), - }) - return paginatedRes?.docs?.[0] || undefined - } - - const paginatedRes = await payload.find({ - ...options, - pagination: false, - where: combineQueries(where, { id: { equals: id } }), - }) - - return paginatedRes?.docs?.[0] || undefined - } - - return payload.findByID({ - id, - collection: entity.slug, - depth: 0, - fallbackLocale: null, - locale, - overrideAccess: true, - req, - trash: true, - }) - } - } - - const createAccessPromise: CreateAccessPromise = async ({ - access, - accessLevel, - disableWhere = false, - operation, - policiesObj, - }) => { - const mutablePolicies = policiesObj as Record - if (accessLevel === 'field' && docBeingAccessed === undefined) { - // assign docBeingAccessed first as the promise to avoid multiple calls to getEntityDoc - docBeingAccessed = getEntityDoc().then((doc) => { - docBeingAccessed = doc - }) - } - - // awaiting the promise to ensure docBeingAccessed is assigned before it is used - await docBeingAccessed - - // https://payloadcms.slack.com/archives/C048Z9C2BEX/p1702054928343769 - const accessResult = await access({ id, data, doc: docBeingAccessed, req }) - - // Where query was returned from access function => check if document is returned when querying with where - if (typeof accessResult === 'object' && !disableWhere) { - mutablePolicies[operation] = { - permission: - id || type === 'global' - ? !!(await getEntityDoc({ operation, where: accessResult })) - : true, - where: accessResult, - } - } else if (mutablePolicies[operation]?.permission !== false) { - mutablePolicies[operation] = { - permission: !!accessResult, - } - } - } - - for (const operation of operations) { - if (typeof entity.access[operation as keyof typeof entity.access] === 'function') { - await createAccessPromise({ - access: entity.access[operation as keyof typeof entity.access], - accessLevel: 'entity', - operation, - policiesObj: policies, - }) - } else { - ;(policies as any)[operation] = { - permission: isLoggedIn, - } - } - - await executeFieldPolicies({ - blockPolicies, - createAccessPromise, - entityPermission: (policies as any)[operation].permission as boolean, - fields: entity.fields, - operation, - payload, - policiesObj: policies, - }) - } - - return policies -} - -/** - * Build up permissions object and run access functions for each field of an entity - */ -const executeFieldPolicies = async ({ - blockPolicies, - createAccessPromise, - entityPermission, - fields, - operation, - payload, - policiesObj, -}: { - blockPolicies: BlockPolicies - createAccessPromise: CreateAccessPromise - entityPermission: boolean - fields: Field[] - operation: AllOperations - payload: Payload - policiesObj: CollectionPermission | FieldsPermissions | GlobalPermission -}) => { - const mutablePolicies = policiesObj.fields as Record - - // Fields don't have all operations of a collection - if (operation === 'delete' || operation === 'readVersions' || operation === 'unlock') { - return - } - - await Promise.all( - fields.map(async (field) => { - if ('name' in field && field.name) { - if (!mutablePolicies[field.name]) { - mutablePolicies[field.name] = {} - } - - if ('access' in field && field.access && typeof field.access[operation] === 'function') { - await createAccessPromise({ - access: field.access[operation], - accessLevel: 'field', - disableWhere: true, - operation, - policiesObj: mutablePolicies[field.name], - }) - } else { - mutablePolicies[field.name][operation] = { - permission: (policiesObj as any)[operation]?.permission, - } - } - - if ('fields' in field && field.fields) { - if (!mutablePolicies[field.name].fields) { - mutablePolicies[field.name].fields = {} - } - - await executeFieldPolicies({ - blockPolicies, - createAccessPromise, - entityPermission, - fields: field.fields, - operation, - payload, - policiesObj: mutablePolicies[field.name], - }) - } - - if ( - ('blocks' in field && field.blocks?.length) || - ('blockReferences' in field && field.blockReferences?.length) - ) { - if (!mutablePolicies[field.name]?.blocks) { - mutablePolicies[field.name].blocks = {} - } - - await Promise.all( - (field.blockReferences ?? field.blocks).map(async (_block) => { - const block = typeof _block === 'string' ? payload.blocks[_block] : _block - - // Skip if block doesn't exist (invalid block reference) - if (!block) { - return - } - - if (typeof _block === 'string') { - if (blockPolicies[_block]) { - if (typeof blockPolicies[_block].then === 'function') { - // Earlier access to this block is still pending, so await it instead of re-running executeFieldPolicies - mutablePolicies[field.name].blocks[block.slug] = await blockPolicies[_block] - } else { - // It's already a resolved policy object - mutablePolicies[field.name].blocks[block.slug] = blockPolicies[_block] - } - return - } else { - // We have not seen this block slug yet. Immediately create a promise - // so that any parallel calls will just await this same promise - // instead of re-running executeFieldPolicies. - blockPolicies[_block] = (async () => { - // If the block doesn't exist yet in our mutablePolicies, initialize it - if (!mutablePolicies[field.name].blocks?.[block.slug]) { - // Use field-level permission instead of entityPermission for blocks - // This ensures that if the field has access control, it applies to all blocks in the field - const fieldPermission = - mutablePolicies[field.name][operation]?.permission ?? entityPermission - - mutablePolicies[field.name].blocks[block.slug] = { - fields: {}, - [operation]: { permission: fieldPermission }, - } - } else if (!mutablePolicies[field.name].blocks[block.slug][operation]) { - // Use field-level permission for consistency - const fieldPermission = - mutablePolicies[field.name][operation]?.permission ?? entityPermission - - mutablePolicies[field.name].blocks[block.slug][operation] = { - permission: fieldPermission, - } - } - - await executeFieldPolicies({ - blockPolicies, - createAccessPromise, - entityPermission: - mutablePolicies[field.name][operation]?.permission ?? entityPermission, - fields: block.fields, - operation, - payload, - policiesObj: mutablePolicies[field.name].blocks[block.slug], - }) - - return mutablePolicies[field.name].blocks[block.slug] - })() - - mutablePolicies[field.name].blocks[block.slug] = await blockPolicies[_block] - blockPolicies[_block] = mutablePolicies[field.name].blocks[block.slug] - return - } - } - - if (!mutablePolicies[field.name].blocks?.[block.slug]) { - // Use field-level permission instead of entityPermission for blocks - const fieldPermission = - mutablePolicies[field.name][operation]?.permission ?? entityPermission - - mutablePolicies[field.name].blocks[block.slug] = { - fields: {}, - [operation]: { permission: fieldPermission }, - } - } else if (!mutablePolicies[field.name].blocks[block.slug][operation]) { - // Use field-level permission for consistency - const fieldPermission = - mutablePolicies[field.name][operation]?.permission ?? entityPermission - - mutablePolicies[field.name].blocks[block.slug][operation] = { - permission: fieldPermission, - } - } - - await executeFieldPolicies({ - blockPolicies, - createAccessPromise, - entityPermission: - mutablePolicies[field.name][operation]?.permission ?? entityPermission, - fields: block.fields, - operation, - payload, - policiesObj: mutablePolicies[field.name].blocks[block.slug], - }) - }), - ) - } - } else if ('fields' in field && field.fields) { - await executeFieldPolicies({ - blockPolicies, - createAccessPromise, - entityPermission, - fields: field.fields, - operation, - payload, - policiesObj, - }) - } else if (field.type === 'tabs') { - await Promise.all( - field.tabs.map(async (tab) => { - if (tabHasName(tab)) { - if (!mutablePolicies[tab.name]) { - mutablePolicies[tab.name] = { - fields: {}, - [operation]: { permission: entityPermission }, - } - } else if (!mutablePolicies[tab.name][operation]) { - mutablePolicies[tab.name][operation] = { permission: entityPermission } - } - await executeFieldPolicies({ - blockPolicies, - createAccessPromise, - entityPermission, - fields: tab.fields, - operation, - payload, - policiesObj: mutablePolicies[tab.name], - }) - } else { - await executeFieldPolicies({ - blockPolicies, - createAccessPromise, - entityPermission, - fields: tab.fields, - operation, - payload, - policiesObj, - }) - } - }), - ) - } - }), - ) -} From 43e99859e9c9894fc536ebc016185284a7bb3068 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Wed, 19 Nov 2025 01:05:15 -0800 Subject: [PATCH 41/42] fix: global access control where --- .../utilities/getEntityPermissions/entityDocExists.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/payload/src/utilities/getEntityPermissions/entityDocExists.ts b/packages/payload/src/utilities/getEntityPermissions/entityDocExists.ts index cf173efae79..2769fda55e5 100644 --- a/packages/payload/src/utilities/getEntityPermissions/entityDocExists.ts +++ b/packages/payload/src/utilities/getEntityPermissions/entityDocExists.ts @@ -27,15 +27,17 @@ export async function entityDocExists({ where: Where }): Promise { if (entityType === 'global') { - // TODO: Write test (should be broken in prev version since we just find without where?), - // perf optimize (returning false or countGlobal or db.globalExists?) const global = await req.payload.db.findGlobal({ slug, locale, req, - where: combineQueries(where, { id: { equals: id } }), + select: {}, + where, }) - return Boolean(global) + + const hasGlobalDoc = Boolean(global && Object.keys(global).length > 0) + + return hasGlobalDoc } if (entityType === 'collection' && id) { From 4f143c84da887574bd52e17739a6cfcd4f7dee0e Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Wed, 19 Nov 2025 10:52:28 -0800 Subject: [PATCH 42/42] payload/internal export --- packages/payload/package.json | 10 ++++++++++ packages/payload/src/exports/internal.ts | 6 ++++++ packages/payload/src/index.ts | 2 -- .../getEntityPermissions/getEntityPermissions.ts | 2 +- packages/payload/src/utilities/sanitizePermissions.ts | 2 +- test/access-control/int.spec.ts | 3 ++- test/access-control/postgres-logs.int.spec.ts | 3 ++- 7 files changed, 22 insertions(+), 6 deletions(-) create mode 100644 packages/payload/src/exports/internal.ts diff --git a/packages/payload/package.json b/packages/payload/package.json index dc4cb0379dd..07ef4afa415 100644 --- a/packages/payload/package.json +++ b/packages/payload/package.json @@ -45,6 +45,11 @@ "types": "./src/index.ts", "default": "./src/index.ts" }, + "./internal": { + "import": "./src/exports/internal.ts", + "types": "./src/exports/internal.ts", + "default": "./src/exports/internal.ts" + }, "./shared": { "import": "./src/exports/shared.ts", "types": "./src/exports/shared.ts", @@ -150,6 +155,11 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./internal": { + "import": "./dist/exports/internal.js", + "types": "./dist/exports/internal.d.ts", + "default": "./dist/exports/internal.js" + }, "./node": { "import": "./dist/exports/node.js", "types": "./dist/exports/node.d.ts", diff --git a/packages/payload/src/exports/internal.ts b/packages/payload/src/exports/internal.ts new file mode 100644 index 00000000000..d84cca7bff0 --- /dev/null +++ b/packages/payload/src/exports/internal.ts @@ -0,0 +1,6 @@ +/** + * Modules exported here are not part of the public API and are subject to change without notice and without a major version bump. + */ + +export { getEntityPermissions } from '../utilities/getEntityPermissions/getEntityPermissions.js' +export { sanitizePermissions } from '../utilities/sanitizePermissions.js' diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index c6fcd1c3e37..67991eda800 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1740,7 +1740,6 @@ export { formatErrors } from './utilities/formatErrors.js' export { formatLabels, formatNames, toWords } from './utilities/formatLabels.js' export { getBlockSelect } from './utilities/getBlockSelect.js' export { getCollectionIDFieldTypes } from './utilities/getCollectionIDFieldTypes.js' -export { getEntityPermissions } from './utilities/getEntityPermissions/getEntityPermissions.js' export { getFieldByPath } from './utilities/getFieldByPath.js' export { getObjectDotNotation } from './utilities/getObjectDotNotation.js' export { getRequestLanguage } from './utilities/getRequestLanguage.js' @@ -1759,7 +1758,6 @@ export { mergeHeaders } from './utilities/mergeHeaders.js' export { parseDocumentID } from './utilities/parseDocumentID.js' export { sanitizeFallbackLocale } from './utilities/sanitizeFallbackLocale.js' export { sanitizeJoinParams } from './utilities/sanitizeJoinParams.js' -export { sanitizePermissions } from './utilities/sanitizePermissions.js' export { sanitizePopulateParam } from './utilities/sanitizePopulateParam.js' export { sanitizeSelectParam } from './utilities/sanitizeSelectParam.js' export { stripUnselectedFields } from './utilities/stripUnselectedFields.js' diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts index 684f77f55ba..e4bf4476051 100644 --- a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -81,7 +81,7 @@ const topLevelGlobalPermissions = ['read', 'readVersions', 'update'] * rows, as we're calculating schema permissions, which do not include individual rows. * For consistency, it's thus better to never include the siblingData and blockData * - * @internal - this function may change or be removed in a minor release. + * @internal */ export async function getEntityPermissions( args: Args, diff --git a/packages/payload/src/utilities/sanitizePermissions.ts b/packages/payload/src/utilities/sanitizePermissions.ts index 6e61263de3d..f71e19532f4 100644 --- a/packages/payload/src/utilities/sanitizePermissions.ts +++ b/packages/payload/src/utilities/sanitizePermissions.ts @@ -203,7 +203,7 @@ export function recursivelySanitizeGlobals(obj: Permissions['globals']): void { /** * Recursively remove empty objects and false values from an object. * - * @internal - this function may change or be removed in a minor release. + * @internal */ export function sanitizePermissions( data: MarkOptional, diff --git a/test/access-control/int.spec.ts b/test/access-control/int.spec.ts index 4c9b1fd42cc..71f4b6ae106 100644 --- a/test/access-control/int.spec.ts +++ b/test/access-control/int.spec.ts @@ -9,7 +9,8 @@ import type { } from 'payload' import path from 'path' -import { createLocalReq, Forbidden, getEntityPermissions } from 'payload' +import { createLocalReq, Forbidden } from 'payload' +import { getEntityPermissions } from 'payload/internal' import { fileURLToPath } from 'url' import type { FullyRestricted, Post } from './payload-types.js' diff --git a/test/access-control/postgres-logs.int.spec.ts b/test/access-control/postgres-logs.int.spec.ts index 42c21ce0d95..5684f9c8a43 100644 --- a/test/access-control/postgres-logs.int.spec.ts +++ b/test/access-control/postgres-logs.int.spec.ts @@ -3,7 +3,8 @@ import type { CollectionPermission, Payload, PayloadRequest } from 'payload' /* eslint-disable jest/require-top-level-describe */ import assert from 'assert' import path from 'path' -import { createLocalReq, getEntityPermissions } from 'payload' +import { createLocalReq } from 'payload' +import { getEntityPermissions } from 'payload/internal' import { fileURLToPath } from 'url' import { initPayloadInt } from '../helpers/initPayloadInt.js'