Files
payloadcms__payload/packages/payload/src/auth/withBaseAccess.ts
German Jablonski ef69ab17ae feat: add base access control (#17662)
## Summary

Adds a top-level `baseAccess` config with separate `collections` and
`globals` access maps. This gives applications and plugins one
core-owned policy layer without iterating over resources or hardcoding
Payload-managed slugs.

## Motivation

This originated from Figma Make's need to enforce a read-only policy for
viewer users across every Payload resource. Its plugin currently has to
find resources and wrap their access callbacks, which is order-dependent
and can miss Payload-managed resources created during sanitization.

Plugins can currently apply a shared policy only by finding every
Collection and Global and wrapping each access callback. That is
order-dependent and can miss resources added by later plugins or by
Payload during sanitization, making omissions difficult to detect and
potentially security-sensitive.

`baseAccess` moves that composition into Payload so the same policy is
applied consistently to configured, plugin-provided, and
Payload-generated resources.

## Alternatives considered

- **Plugin ordering and callback wrapping:** still depends on when
resources are registered and cannot reliably cover resources created
during sanitization.
- **One boolean-only function:** simpler, but cannot express the `Where`
constraints supported by existing Collection and Global Access Control.
- **A new policies API:** more flexible, but introduces a larger new
abstraction when the existing access shapes already describe the
required operations.

## Changes

- Reuses `CollectionAccess` and `GlobalAccess` directly for
`baseAccess.collections` and `baseAccess.globals`
- Adds the resource `slug` to all Collection and Global Access Control
callback arguments, including `access.admin`
- Combines each configured base function with the matching resource
access function using AND semantics
- Applies the policy to resources created during sanitization, including
Payload-generated resources
- Resolves the base function from the current request so reused
sanitized resources cannot retain another config's policy
- Fails closed when either access function returns an invalid falsy
result
- Preserves `overrideAccess` behavior and existing resource-specific
access
- Keeps `baseAccess` out of the client config
- Rejects query constraints returned by collection base access for
create operations, which only support boolean access
- Documents the API, composition rules, scope, and Payload 4 migration
note

---------

Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com>
2026-08-12 14:07:45 -04:00

112 lines
2.5 KiB
TypeScript

import type { CollectionAccess } from '../collections/config/types.js'
import type { Access, AccessArgs, AccessResult } from '../config/types.js'
import type { GlobalAccess } from '../globals/config/types.js'
type CommonArgs = {
access?: Access
slug: string
}
type AdminAccess = NonNullable<CollectionAccess['admin']>
type Args = (
| {
entityType: 'collection'
operation: Exclude<keyof CollectionAccess, 'admin'>
}
| {
entityType: 'global'
operation: keyof GlobalAccess
}
) &
CommonArgs
const authenticatedAccess: Access = ({ req }) => Boolean(req.user)
export const withBaseAccess = (options: Args): Access => {
const documentAccess = options.access ?? authenticatedAccess
return async (args: AccessArgs): Promise<AccessResult> => {
const accessArgs = {
...args,
slug: options.slug,
}
const { baseAccess } = args.req.payload.config
const baseAccessFunction =
options.entityType === 'collection'
? baseAccess?.collections?.[options.operation]
: baseAccess?.globals?.[options.operation]
if (!baseAccessFunction) {
return documentAccess(accessArgs)
}
const baseResult = await baseAccessFunction(accessArgs)
if (!baseResult) {
return false
}
if (
options.entityType === 'collection' &&
options.operation === 'create' &&
typeof baseResult === 'object'
) {
throw new Error('baseAccess must return a boolean for collection create operations.')
}
const documentResult = await documentAccess(accessArgs)
if (!documentResult) {
return false
}
if (baseResult === true) {
return documentResult
}
if (documentResult === true) {
return baseResult
}
return {
and: [baseResult, documentResult],
}
}
}
export const withBaseAdminAccess = ({
slug,
access,
}: {
access?: AdminAccess
slug: string
}): AdminAccess => {
const documentAccess =
access ?? (({ req }) => Boolean(req.user && req.payload.config.admin.user === slug))
return async (args) => {
const accessArgs = {
...args,
slug,
}
const baseAccessFunction = args.req.payload.config.baseAccess?.collections?.admin
if (!baseAccessFunction) {
return documentAccess(accessArgs)
}
const baseResult = await baseAccessFunction(accessArgs)
if (!baseResult) {
return false
}
if (typeof baseResult === 'object') {
throw new Error('baseAccess must return a boolean for collection admin operations.')
}
return documentAccess(accessArgs)
}
}