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/next/src/views/Document/getDocumentPermissions.tsx b/packages/next/src/views/Document/getDocumentPermissions.tsx
index 3e37739a9ad..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<{
@@ -35,29 +38,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 +68,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/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/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/auth/types.ts b/packages/payload/src/auth/types.ts
index 10281630329..77d3d13b313 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 =
| {
@@ -42,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 =
@@ -65,12 +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
- update: Permission
+ // Auth-enabled Collections only
+ unlock?: Permission
+ update?: Permission
}
export type SanitizedCollectionPermission = {
@@ -79,14 +79,16 @@ export type SanitizedCollectionPermission = {
fields: SanitizedFieldsPermissions
read?: true
readVersions?: true
+ // Auth-enabled Collections only
+ unlock?: true
update?: true
}
export type GlobalPermission = {
fields: FieldsPermissions
- read: Permission
+ read?: Permission
readVersions?: Permission
- update: Permission
+ update?: Permission
}
export type SanitizedGlobalPermission = {
diff --git a/packages/payload/src/collections/operations/docAccess.ts b/packages/payload/src/collections/operations/docAccess.ts
index 29fa0893371..61245dc69e5 100644
--- a/packages/payload/src/collections/operations/docAccess.ts
+++ b/packages/payload/src/collections/operations/docAccess.ts
@@ -1,8 +1,8 @@
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 { getEntityPolicies } from '../../utilities/getEntityPolicies.js'
+import { getEntityPermissions } from '../../utilities/getEntityPermissions/getEntityPermissions.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { sanitizePermissions } from '../../utilities/sanitizePermissions.js'
@@ -10,7 +10,14 @@ const allOperations: AllOperations[] = ['create', 'read', 'update', 'delete']
type Arguments = {
collection: Collection
- id: number | string
+ /**
+ * If the document data is passed, it will be used to check access instead of fetching the document from the database.
+ */
+ data?: JsonObject
+ /**
+ * When called for creating a new document, id is not provided.
+ */
+ id?: number | string
req: PayloadRequest
}
@@ -18,6 +25,7 @@ export async function docAccessOperation(args: Arguments): Promise = {
*/
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/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 42e1d86b8e7..9d1075b1a6f 100644
--- a/packages/payload/src/database/queryValidation/validateSearchParams.ts
+++ b/packages/payload/src/database/queryValidation/validateSearchParams.ts
@@ -5,7 +5,7 @@ import type { PayloadRequest, WhereField } from '../../types/index.js'
import type { EntityPolicies, PathToQuery } from './types.js'
import { fieldAffectsData } from '../../fields/config/types.js'
-import { getEntityPolicies } from '../../utilities/getEntityPolicies.js'
+import { getEntityPermissions } from '../../utilities/getEntityPermissions/getEntityPermissions.js'
import { isolateObjectProperty } from '../../utilities/isolateObjectProperty.js'
import { getLocalizedPaths } from '../getLocalizedPaths.js'
import { validateQueryPaths } from './validateQueryPaths.js'
@@ -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
@@ -56,13 +57,14 @@ export async function validateSearchParam({
let paths: PathToQuery[] = []
const { slug } = (collectionConfig || globalConfig)!
- const blockPolicies = {}
+ const blockReferencesPermissions = {}
if (globalConfig && !policies.globals![slug]) {
- policies.globals![slug] = await getEntityPolicies({
- type: 'global',
- blockPolicies,
+ policies.globals![slug] = await getEntityPermissions({
+ blockReferencesPermissions,
entity: globalConfig,
+ entityType: 'global',
+ fetchData: false,
operations: ['read'],
req,
})
@@ -123,10 +125,11 @@ export async function validateSearchParam({
if (!overrideAccess && fieldAffectsData(field)) {
if (collectionSlug) {
if (!policies.collections![collectionSlug]) {
- policies.collections![collectionSlug] = await getEntityPolicies({
- type: 'collection',
- blockPolicies,
+ policies.collections![collectionSlug] = await getEntityPermissions({
+ blockReferencesPermissions,
entity: req.payload.collections[collectionSlug]!.config,
+ entityType: 'collection',
+ fetchData: false,
operations: ['read'],
req: isolateObjectProperty(req, 'transactionID'),
})
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/globals/operations/docAccess.ts b/packages/payload/src/globals/operations/docAccess.ts
index b0683b806af..898df87efe3 100644
--- a/packages/payload/src/globals/operations/docAccess.ts
+++ b/packages/payload/src/globals/operations/docAccess.ts
@@ -1,20 +1,24 @@
import type { SanitizedGlobalPermission } from '../../auth/index.js'
-import type { AllOperations, PayloadRequest } from '../../types/index.js'
+import type { AllOperations, JsonObject, PayloadRequest } from '../../types/index.js'
import type { SanitizedGlobalConfig } from '../config/types.js'
import { commitTransaction } from '../../utilities/commitTransaction.js'
-import { getEntityPolicies } from '../../utilities/getEntityPolicies.js'
+import { getEntityPermissions } from '../../utilities/getEntityPermissions/getEntityPermissions.js'
import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { sanitizePermissions } from '../../utilities/sanitizePermissions.js'
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
globalConfig: SanitizedGlobalConfig
req: PayloadRequest
}
export const docAccessOperation = async (args: Arguments): Promise => {
- const { globalConfig, req } = args
+ const { data, globalConfig, req } = args
const globalOperations: AllOperations[] = ['read', 'update']
@@ -24,10 +28,13 @@ export const docAccessOperation = async (args: Arguments): Promise {
+ if (entityType === 'global') {
+ const global = await req.payload.db.findGlobal({
+ slug,
+ locale,
+ req,
+ select: {},
+ where,
+ })
+
+ const hasGlobalDoc = Boolean(global && Object.keys(global).length > 0)
+
+ return hasGlobalDoc
+ }
+
+ 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..e4bf4476051
--- /dev/null
+++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts
@@ -0,0 +1,304 @@
+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 { SanitizedGlobalConfig } from '../../globals/config/types.js'
+import type { BlockSlug, DefaultDocumentIDType } from '../../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
+>
+
+export type EntityDoc = JsonObject | TypeWithID
+
+type ReturnType = TEntityType extends 'global'
+ ? GlobalPermission
+ : CollectionPermission
+
+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
+ /**
+ * 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',
+ 'unlock',
+]
+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
+ *
+ * @internal
+ */
+export async function getEntityPermissions(
+ args: Args,
+): Promise> {
+ const {
+ id,
+ blockReferencesPermissions,
+ data: _data,
+ entity,
+ entityType,
+ fetchData,
+ operations,
+ req,
+ } = args
+ const { locale: _locale, user } = req
+
+ const locale = _locale ? _locale : undefined
+
+ if (fetchData && entityType === 'collection' && !id) {
+ throw new Error('ID is required when fetching data for a collection')
+ }
+
+ const hasData = _data && Object.keys(_data).length > 0
+ 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
+
+ const fieldsPermissions: FieldsPermissions = {}
+
+ const entityPermissions: ReturnType = {
+ fields: fieldsPermissions,
+ } as ReturnType
+
+ 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]
+
+ if (
+ (entityType === 'collection' && topLevelCollectionPermissions.includes(operation)) ||
+ (entityType === 'global' && topLevelGlobalPermissions.includes(operation))
+ ) {
+ if (typeof accessFunction === 'function') {
+ accessResults.push({
+ operation,
+ result: Promise.resolve(accessFunction({ id, data, req })) as Promise,
+ })
+ } else {
+ entityPermissions[operation] = {
+ permission: isLoggedIn,
+ }
+ }
+ }
+ }
+
+ // Await all access functions in parallel
+ const resolvedAccessResults = await Promise.all(
+ accessResults.map(async (item) => ({
+ operation: item.operation,
+ result: await item.result,
+ })),
+ )
+
+ // 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,
+ 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
+}
+
+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
+ req: PayloadRequest
+ slug: string
+ 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))
+
+ 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'.
+ // 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/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts
new file mode 100644
index 00000000000..5d3eb50a8c6
--- /dev/null
+++ b/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts
@@ -0,0 +1,321 @@
+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'
+
+/**
+ * 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)) {
+ // 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) => {
+ // Mutate the permission property in place so all references see the update
+ permissionObj.permission = result
+ })
+
+ 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
+ */
+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) {
+ // Set up permissions for all operations
+ for (const operation of operations) {
+ const parentPermissionForOperation = (
+ parentPermissionsObject[operation as keyof typeof parentPermissionsObject] as Permission
+ )?.permission
+
+ // 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
+ })
+
+ // Handle both sync and async access results
+ if (isThenable(accessResult)) {
+ const booleanPromise = accessResult.then((result) => Boolean(result))
+ setPermission(fieldPermissions, operation, booleanPromise, promises)
+ } else {
+ setPermission(fieldPermissions, operation, Boolean(accessResult), promises)
+ }
+ } else {
+ // Inherit from parent (which might be a promise)
+ setPermission(fieldPermissions, operation, parentPermissionForOperation, promises)
+ }
+ }
+ }
+
+ // Handle named fields with nested content
+ if ('name' in field && field.name) {
+ const fieldPermissions: FieldPermissions = permissionsObject[field.name]!
+
+ if ('fields' in field && field.fields) {
+ if (!fieldPermissions.fields) {
+ fieldPermissions.fields = {}
+ }
+
+ 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
+
+ // 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 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
+
+ // Skip if block doesn't exist (invalid block reference)
+ if (!block) {
+ continue
+ }
+
+ // Handle block references - check if we've seen this block before
+ if (typeof _block === 'string') {
+ const blockReferencePermissions = blockReferencesPermissions[_block]
+ if (blockReferencePermissions) {
+ // Reference the cached permissions (may be a promise or resolved object)
+ blocksPermissions[block.slug] = blockReferencePermissions as BlockPermissions
+ continue
+ }
+ }
+
+ // Initialize block permissions object if needed
+ if (!blocksPermissions[block.slug]) {
+ blocksPermissions[block.slug] = {} as BlockPermissions
+ }
+
+ const blockPermission = blocksPermissions[block.slug]!
+
+ // Set permission for this operation
+ if (!blockPermission[operation]) {
+ const fieldPermission =
+ fieldPermissions[operation]?.permission ?? parentPermissionForOperation
+
+ // Inherit from field permission (which might be a promise)
+ setPermission(blockPermission, operation, fieldPermission, promises)
+ }
+ }
+ }
+
+ // 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
+
+ // Skip if block doesn't exist (invalid block reference)
+ if (!block || processedBlocks.has(block.slug)) {
+ continue
+ }
+ processedBlocks.add(block.slug)
+
+ const blockPermission = blocksPermissions[block.slug]
+ if (!blockPermission) {
+ continue
+ }
+
+ if (!blockPermission.fields) {
+ blockPermission.fields = {}
+ }
+
+ // 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,
+ fields: block.fields,
+ operations,
+ parentPermissionsObject: blockPermission,
+ permissionsObject: blockPermission.fields,
+ promises,
+ req,
+ })
+ }
+ }
+ }
+
+ // Handle unnamed group fields
+ if ('fields' in field && field.fields && !('name' in field && field.name)) {
+ // Field does not have a name => same parentPermissionsObject
+ 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]) {
+ 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)
+ }
+ }
+ }
+ }
+
+ for (const tab of field.tabs) {
+ if (tabHasName(tab)) {
+ const tabPermissions: FieldPermissions = permissionsObject[tab.name]!
+
+ if (!tabPermissions.fields) {
+ tabPermissions.fields = {}
+ }
+
+ populateFieldPermissions({
+ id,
+ blockReferencesPermissions,
+ data,
+ fields: tab.fields,
+ operations,
+ parentPermissionsObject: tabPermissions,
+ permissionsObject: tabPermissions.fields,
+ promises,
+ req,
+ })
+ } else {
+ // Tab does not have a name => same parentPermissionsObject
+ populateFieldPermissions({
+ id,
+ blockReferencesPermissions,
+ data,
+ fields: tab.fields,
+ operations,
+ // Tab does not have a name here => use parent permissions object
+ parentPermissionsObject,
+ permissionsObject,
+ promises,
+ req,
+ })
+ }
+ }
+ }
+ }
+}
diff --git a/packages/payload/src/utilities/getEntityPolicies.ts b/packages/payload/src/utilities/getEntityPolicies.ts
deleted file mode 100644
index 1daaad44f2e..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)
- */
-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,
- })
- }
- }),
- )
- }
- }),
- )
-}
diff --git a/packages/payload/src/utilities/sanitizePermissions.ts b/packages/payload/src/utilities/sanitizePermissions.ts
index e5d06cec0eb..f71e19532f4 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) {
@@ -200,6 +202,8 @@ export function recursivelySanitizeGlobals(obj: Permissions['globals']): void {
/**
* Recursively remove empty objects and false values from an object.
+ *
+ * @internal
*/
export function sanitizePermissions(
data: MarkOptional,
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
},
},
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 eb3ff36b6c1..8340359cbe0 100644
--- a/test/access-control/config.ts
+++ b/test/access-control/config.ts
@@ -1,877 +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,
- ],
- 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/e2e.spec.ts b/test/access-control/e2e.spec.ts
index 23e490a4e8a..cbe9f3e3860 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,87 @@ 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',
+ where: {
+ id: {
+ exists: true,
+ },
+ },
+ })
+
+ 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',
+ where: {
+ id: {
+ exists: true,
+ },
+ },
+ })
+ })
})
describe('rich text', () => {
diff --git a/test/access-control/getConfig.ts b/test/access-control/getConfig.ts
new file mode 100644
index 00000000000..32f3b83dce4
--- /dev/null
+++ b/test/access-control/getConfig.ts
@@ -0,0 +1,1036 @@
+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,
+ },
+ ],
+ },
+ // 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: [
+ {
+ 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..71f4b6ae106 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,14 +9,16 @@ import type {
} from 'payload'
import path from 'path'
-import { Forbidden, ValidationError } from 'payload'
+import { createLocalReq, Forbidden } from 'payload'
+import { getEntityPermissions } from 'payload/internal'
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 {
+ asyncParentSlug,
firstArrayText,
fullyRestrictedSlug,
hiddenAccessCountSlug,
@@ -727,6 +730,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 1a0409d62e8..7dc0731c903 100644
--- a/test/access-control/payload-types.ts
+++ b/test/access-control/payload-types.ts
@@ -97,6 +97,10 @@ export interface Config {
hooks: Hook;
'auth-collection': AuthCollection;
'read-restricted': ReadRestricted;
+ '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;
@@ -130,6 +134,10 @@ export interface Config {
hooks: HooksSelect | HooksSelect;
'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;
+ 'async-parent': AsyncParentSelect | AsyncParentSelect;
'payload-kv': PayloadKvSelect | PayloadKvSelect;
'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect;
'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect;
@@ -138,6 +146,7 @@ export interface Config {
db: {
defaultIDType: string;
};
+ fallbackLocale: null;
globals: {
settings: Setting;
test: Test;
@@ -883,6 +892,60 @@ 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: 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` "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".
@@ -1010,6 +1073,22 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'read-restricted';
value: string | ReadRestricted;
+ } | null)
+ | ({
+ relationTo: 'field-restricted-update-based-on-data';
+ value: string | FieldRestrictedUpdateBasedOnDatum;
+ } | null)
+ | ({
+ relationTo: 'where-cache-same';
+ value: string | WhereCacheSame;
+ } | null)
+ | ({
+ relationTo: 'where-cache-unique';
+ value: string | WhereCacheUnique;
+ } | null)
+ | ({
+ relationTo: 'async-parent';
+ value: string | AsyncParent;
} | null);
globalSlug?: string | null;
user:
@@ -1593,6 +1672,60 @@ 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` "where-cache-same_select".
+ */
+export interface WhereCacheSameSelect {
+ 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` "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/postgres-logs.int.spec.ts b/test/access-control/postgres-logs.int.spec.ts
new file mode 100644
index 00000000000..5684f9c8a43
--- /dev/null
+++ b/test/access-control/postgres-logs.int.spec.ts
@@ -0,0 +1,307 @@
+import type { CollectionPermission, Payload, PayloadRequest } from 'payload'
+
+/* eslint-disable jest/require-top-level-describe */
+import assert from 'assert'
+import path from 'path'
+import { createLocalReq } from 'payload'
+import { getEntityPermissions } from 'payload/internal'
+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)
+ })
+
+ 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.
+ 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)
+ })
+ })
+ })
+})
diff --git a/test/access-control/shared.ts b/test/access-control/shared.ts
index 2fff587bb61..e860ebfc64f 100644
--- a/test/access-control/shared.ts
+++ b/test/access-control/shared.ts
@@ -28,3 +28,7 @@ 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'
+export const asyncParentSlug = 'async-parent'
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)
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".