feat: sanitise access endpoint (#7335)

Protects the `/api/access` endpoint behind authentication and sanitizes
the result, making it more secure and significantly smaller. To do this:

1. The `permission` keyword is completely omitted from the result
2. Only _truthy_ access results are returned
3. All nested permissions are consolidated when possible

---------

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
Co-authored-by: James <james@trbl.design>
This commit is contained in:
Paul
2024-11-15 14:08:06 -06:00
committed by GitHub
parent 0b9d5a5ae4
commit 26ffbca914
72 changed files with 998 additions and 228 deletions

View File

@@ -1,9 +1,9 @@
import type { I18n } from '@payloadcms/translations'
import type {
Payload,
Permissions,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
SanitizedPermissions,
} from 'payload'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
@@ -23,7 +23,7 @@ export const DocumentTabs: React.FC<{
globalConfig: SanitizedGlobalConfig
i18n: I18n
payload: Payload
permissions: Permissions
permissions: SanitizedPermissions
}> = (props) => {
const { collectionConfig, globalConfig, i18n, payload, permissions } = props
const { config } = payload

View File

@@ -72,9 +72,8 @@ export const tabs: Record<
condition: ({ collectionConfig, globalConfig, permissions }) =>
Boolean(
(collectionConfig?.versions &&
permissions?.collections?.[collectionConfig?.slug]?.readVersions?.permission) ||
(globalConfig?.versions &&
permissions?.globals?.[globalConfig?.slug]?.readVersions?.permission),
permissions?.collections?.[collectionConfig?.slug]?.readVersions) ||
(globalConfig?.versions && permissions?.globals?.[globalConfig?.slug]?.readVersions),
),
href: '/versions',
label: ({ t }) => t('version:versions'),

View File

@@ -1,9 +1,9 @@
import type { I18n } from '@payloadcms/translations'
import type {
Payload,
Permissions,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
SanitizedPermissions,
} from 'payload'
import { Gutter, RenderTitle } from '@payloadcms/ui'
@@ -20,7 +20,7 @@ export const DocumentHeader: React.FC<{
hideTabs?: boolean
i18n: I18n
payload: Payload
permissions: Permissions
permissions: SanitizedPermissions
}> = (props) => {
const { collectionConfig, globalConfig, hideTabs, i18n, payload, permissions } = props

View File

@@ -1,5 +1,5 @@
import type { I18n, I18nClient } from '@payloadcms/translations'
import type { PayloadRequest, Permissions, SanitizedConfig, User } from 'payload'
import type { PayloadRequest, SanitizedConfig, SanitizedPermissions, User } from 'payload'
import { initI18n } from '@payloadcms/translations'
import { headers as getHeaders } from 'next/headers.js'
@@ -11,7 +11,7 @@ import { getRequestLanguage } from './getRequestLanguage.js'
type Result = {
i18n: I18nClient
permissions: Permissions
permissions: SanitizedPermissions
req: PayloadRequest
user: User
}

View File

@@ -2,10 +2,10 @@
import type { FormProps, UserWithToken } from '@payloadcms/ui'
import type {
ClientCollectionConfig,
DocumentPermissions,
DocumentPreferences,
FormState,
LoginWithUsernameOptions,
SanitizedDocumentPermissions,
} from 'payload'
import {
@@ -24,7 +24,7 @@ import { abortAndIgnore } from '@payloadcms/ui/shared'
import React, { useEffect } from 'react'
export const CreateFirstUserClient: React.FC<{
docPermissions: DocumentPermissions
docPermissions: SanitizedDocumentPermissions
docPreferences: DocumentPreferences
initialState: FormState
loginWithUsername?: false | LoginWithUsernameOptions
@@ -114,7 +114,7 @@ export const CreateFirstUserClient: React.FC<{
parentIndexPath=""
parentPath=""
parentSchemaPath={userSlug}
permissions={null}
permissions={true}
readOnly={false}
/>
<FormSubmit size="large">{t('general:create')}</FormSubmit>

View File

@@ -1,5 +1,5 @@
import type { groupNavItems } from '@payloadcms/ui/shared'
import type { ClientUser, Permissions, ServerProps, VisibleEntities } from 'payload'
import type { ClientUser, SanitizedPermissions, ServerProps, VisibleEntities } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { Button, Card, Gutter, Locked } from '@payloadcms/ui'
@@ -19,7 +19,7 @@ export type DashboardProps = {
}>
Link: React.ComponentType<any>
navGroups?: ReturnType<typeof groupNavItems>
permissions: Permissions
permissions: SanitizedPermissions
visibleEntities: VisibleEntities
} & ServerProps
@@ -94,7 +94,7 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
path: `/collections/${slug}/create`,
})
hasCreatePermission = permissions?.collections?.[slug]?.create?.permission
hasCreatePermission = permissions?.collections?.[slug]?.create
}
if (type === EntityType.global) {

View File

@@ -35,14 +35,13 @@ export const Dashboard: React.FC<AdminViewProps> = async ({
const collections = config.collections.filter(
(collection) =>
permissions?.collections?.[collection.slug]?.read?.permission &&
permissions?.collections?.[collection.slug]?.read &&
visibleEntities.collections.includes(collection.slug),
)
const globals = config.globals.filter(
(global) =>
permissions?.globals?.[global.slug]?.read?.permission &&
visibleEntities.globals.includes(global.slug),
permissions?.globals?.[global.slug]?.read && visibleEntities.globals.includes(global.slug),
)
// Query locked global documents only if there are globals in the config

View File

@@ -3,6 +3,7 @@ import type {
DocumentPermissions,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedDocumentPermissions,
SanitizedGlobalConfig,
} from 'payload'
@@ -10,7 +11,7 @@ import {
hasSavePermission as getHasSavePermission,
isEditing as getIsEditing,
} from '@payloadcms/ui/shared'
import { docAccessOperation, docAccessOperationGlobal } from 'payload'
import { docAccessOperation, docAccessOperationGlobal, sanitizePermissions } from 'payload'
export const getDocumentPermissions = async (args: {
collectionConfig?: SanitizedCollectionConfig
@@ -19,7 +20,7 @@ export const getDocumentPermissions = async (args: {
id?: number | string
req: PayloadRequest
}): Promise<{
docPermissions: DocumentPermissions
docPermissions: SanitizedDocumentPermissions
hasPublishPermission: boolean
hasSavePermission: boolean
}> => {
@@ -91,9 +92,13 @@ export const getDocumentPermissions = async (args: {
}
}
// TODO: do this in a better way. Only doing this bc this is how the fn was written (mutates the original object)
const sanitizedDocPermissions = { ...docPermissions } as any as SanitizedDocumentPermissions
sanitizePermissions(sanitizedDocPermissions)
const hasSavePermission = getHasSavePermission({
collectionSlug: collectionConfig?.slug,
docPermissions,
docPermissions: sanitizedDocPermissions,
globalSlug: globalConfig?.slug,
isEditing: getIsEditing({
id,
@@ -103,7 +108,7 @@ export const getDocumentPermissions = async (args: {
})
return {
docPermissions,
docPermissions: sanitizedDocPermissions,
hasPublishPermission,
hasSavePermission,
}

View File

@@ -1,14 +1,14 @@
import type {
DocumentPermissions,
Payload,
SanitizedCollectionConfig,
SanitizedDocumentPermissions,
SanitizedGlobalConfig,
TypedUser,
} from 'payload'
type Args = {
collectionConfig?: SanitizedCollectionConfig
docPermissions: DocumentPermissions
docPermissions: SanitizedDocumentPermissions
globalConfig?: SanitizedGlobalConfig
id?: number | string
locale?: string
@@ -43,7 +43,7 @@ export const getVersions = async ({
const entityConfig = collectionConfig || globalConfig
const versionsConfig = entityConfig?.versions
const shouldFetchVersions = Boolean(versionsConfig && docPermissions?.readVersions?.permission)
const shouldFetchVersions = Boolean(versionsConfig && docPermissions?.readVersions)
if (!shouldFetchVersions) {
const hasPublishedDoc = Boolean((collectionConfig && id) || globalConfig)

View File

@@ -1,11 +1,11 @@
import type {
AdminViewProps,
CollectionPermission,
GlobalPermission,
PayloadComponent,
SanitizedCollectionConfig,
SanitizedCollectionPermission,
SanitizedConfig,
SanitizedGlobalConfig,
SanitizedGlobalPermission,
ServerSideEditViewProps,
} from 'payload'
import type React from 'react'
@@ -38,7 +38,7 @@ export const getViewsFromConfig = ({
routeSegments: string[]
} & (
| {
docPermissions: CollectionPermission | GlobalPermission
docPermissions: SanitizedCollectionPermission | SanitizedGlobalPermission
overrideDocPermissions?: false | undefined
}
| {
@@ -78,7 +78,7 @@ export const getViewsFromConfig = ({
const [collectionEntity, collectionSlug, segment3, segment4, segment5, ...remainingSegments] =
routeSegments
if (!overrideDocPermissions && !docPermissions?.read?.permission) {
if (!overrideDocPermissions && !docPermissions?.read) {
throw new Error('not-found')
} else {
// `../:id`, or `../create`
@@ -86,11 +86,7 @@ export const getViewsFromConfig = ({
case 3: {
switch (segment3) {
case 'create': {
if (
!overrideDocPermissions &&
'create' in docPermissions &&
docPermissions?.create?.permission
) {
if (!overrideDocPermissions && 'create' in docPermissions && docPermissions.create) {
CustomView = {
ComponentConfig: getCustomViewByKey(views, 'default'),
}
@@ -176,7 +172,7 @@ export const getViewsFromConfig = ({
}
case 'versions': {
if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
if (!overrideDocPermissions && docPermissions?.readVersions) {
CustomView = {
ComponentConfig: getCustomViewByKey(views, 'versions'),
}
@@ -229,7 +225,7 @@ export const getViewsFromConfig = ({
// `../:id/versions/:version`, etc
default: {
if (segment4 === 'versions') {
if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
if (!overrideDocPermissions && docPermissions?.readVersions) {
CustomView = {
ComponentConfig: getCustomViewByKey(views, 'version'),
}
@@ -281,7 +277,7 @@ export const getViewsFromConfig = ({
if (globalConfig) {
const [globalEntity, globalSlug, segment3, ...remainingSegments] = routeSegments
if (!overrideDocPermissions && !docPermissions?.read?.permission) {
if (!overrideDocPermissions && !docPermissions?.read) {
throw new Error('not-found')
} else {
switch (routeSegments.length) {
@@ -323,7 +319,7 @@ export const getViewsFromConfig = ({
}
case 'versions': {
if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
if (!overrideDocPermissions && docPermissions?.readVersions) {
CustomView = {
ComponentConfig: getCustomViewByKey(views, 'versions'),
}
@@ -340,7 +336,7 @@ export const getViewsFromConfig = ({
}
default: {
if (!overrideDocPermissions && docPermissions?.read?.permission) {
if (!overrideDocPermissions && docPermissions?.read) {
const baseRoute = [adminRoute, globalEntity, globalSlug, segment3]
.filter(Boolean)
.join('/')
@@ -381,7 +377,7 @@ export const getViewsFromConfig = ({
default: {
// `../:slug/versions/:version`, etc
if (segment3 === 'versions') {
if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
if (!overrideDocPermissions && docPermissions?.readVersions) {
CustomView = {
ComponentConfig: getCustomViewByKey(views, 'version'),
}

View File

@@ -1,9 +1,9 @@
import type {
DefaultServerFunctionArgs,
DocumentPermissions,
DocumentSlots,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedDocumentPermissions,
SanitizedGlobalConfig,
StaticDescription,
} from 'payload'
@@ -18,7 +18,7 @@ export const renderDocumentSlots: (args: {
collectionConfig?: SanitizedCollectionConfig
globalConfig?: SanitizedGlobalConfig
hasSavePermission: boolean
permissions: DocumentPermissions
permissions: SanitizedDocumentPermissions
req: PayloadRequest
}) => DocumentSlots = (args) => {
const { collectionConfig, globalConfig, hasSavePermission, req } = args

View File

@@ -66,7 +66,7 @@ export const renderListView = async (
visibleEntities,
} = initPageResult
if (!permissions?.collections?.[collectionSlug]?.read?.permission) {
if (!permissions?.collections?.[collectionSlug]?.read) {
throw new Error('not-found')
}
@@ -190,7 +190,7 @@ export const renderListView = async (
const sharedClientProps: ListComponentClientProps = {
collectionSlug,
hasCreatePermission: permissions?.collections?.[collectionSlug]?.create?.permission,
hasCreatePermission: permissions?.collections?.[collectionSlug]?.create,
newDocumentURL: formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/create`,

View File

@@ -65,7 +65,7 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
const comparison = compareValue?.value && currentComparisonDoc?.version // the `version` key is only present on `versions` documents
const canUpdate = docPermissions?.update?.permission
const canUpdate = docPermissions?.update
const localeValues = locales && locales.map((locale) => locale.value)

View File

@@ -1,4 +1,9 @@
import type { CollectionPermission, Document, GlobalPermission, OptionObject } from 'payload'
import type {
Document,
OptionObject,
SanitizedCollectionPermission,
SanitizedGlobalPermission,
} from 'payload'
export type CompareOption = {
label: React.ReactNode | string
@@ -9,7 +14,7 @@ export type CompareOption = {
export type DefaultVersionsViewProps = {
readonly doc: Document
readonly docPermissions: CollectionPermission | GlobalPermission
readonly docPermissions: SanitizedCollectionPermission | SanitizedGlobalPermission
readonly initialComparisonDoc: Document
readonly latestDraftVersion?: string
readonly latestPublishedVersion?: string

View File

@@ -1,5 +1,5 @@
import type { I18nClient } from '@payloadcms/translations'
import type { ClientField, FieldPermissions } from 'payload'
import type { ClientField, SanitizedFieldPermissions } from 'payload'
import type React from 'react'
import type { DiffMethod } from 'react-diff-viewer-continued'
@@ -16,6 +16,10 @@ export type DiffComponentProps<TField extends ClientField = ClientField> = {
readonly isRichText?: boolean
readonly locale?: string
readonly locales?: string[]
readonly permissions?: Record<string, FieldPermissions>
readonly permissions?:
| {
[key: string]: SanitizedFieldPermissions
}
| true
readonly version: any
}

View File

@@ -50,11 +50,13 @@ const RenderFieldsToDiff: React.FC<Props> = ({
? JSON.stringify(comparison?.[fieldName])
: comparison?.[fieldName]
const hasPermission = fieldPermissions?.[fieldName]?.read?.permission
const hasPermission =
fieldPermissions?.[fieldName] === true || fieldPermissions?.[fieldName]?.read
const subFieldPermissions = fieldPermissions?.[fieldName]?.fields
const subFieldPermissions =
fieldPermissions?.[fieldName] === true || fieldPermissions?.[fieldName]?.fields
if (hasPermission === false) {
if (!hasPermission) {
return null
}

View File

@@ -1,5 +1,5 @@
import type { I18nClient } from '@payloadcms/translations'
import type { ClientField, FieldPermissions } from 'payload'
import type { ClientField, SanitizedFieldPermissions } from 'payload'
import type { DiffMethod } from 'react-diff-viewer-continued'
import type { DiffComponents } from './fields/types.js'
@@ -7,7 +7,11 @@ import type { DiffComponents } from './fields/types.js'
export type Props = {
readonly comparison: Record<string, any>
readonly diffComponents: DiffComponents
readonly fieldPermissions: Record<string, FieldPermissions>
readonly fieldPermissions:
| {
[key: string]: SanitizedFieldPermissions
}
| true
readonly fields: ClientField[]
readonly i18n: I18nClient
readonly locales: string[]

View File

@@ -1,10 +1,10 @@
import type {
CollectionPermission,
Document,
EditViewComponent,
GlobalPermission,
OptionObject,
PayloadServerReactComponent,
SanitizedCollectionPermission,
SanitizedGlobalPermission,
} from 'payload'
import { notFound } from 'next/navigation.js'
@@ -33,7 +33,7 @@ export const VersionView: PayloadServerReactComponent<EditViewComponent> = async
const { localization } = config
let docPermissions: CollectionPermission | GlobalPermission
let docPermissions: SanitizedCollectionPermission | SanitizedGlobalPermission
let slug: string
let doc: Document

View File

@@ -1,6 +1,6 @@
import type { I18n } from '@payloadcms/translations'
import type { Permissions } from '../../auth/types.js'
import type { SanitizedPermissions } from '../../auth/types.js'
import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
import type { PayloadComponent, SanitizedConfig } from '../../config/types.js'
import type { SanitizedGlobalConfig } from '../../globals/config/types.js'
@@ -12,14 +12,14 @@ export type DocumentTabProps = {
readonly globalConfig?: SanitizedGlobalConfig
readonly i18n: I18n
readonly payload: Payload
readonly permissions: Permissions
readonly permissions: SanitizedPermissions
}
export type DocumentTabCondition = (args: {
collectionConfig: SanitizedCollectionConfig
config: SanitizedConfig
globalConfig: SanitizedGlobalConfig
permissions: Permissions
permissions: SanitizedPermissions
}) => boolean
// Everything is optional because we merge in the defaults

View File

@@ -1,7 +1,7 @@
import type { I18nClient } from '@payloadcms/translations'
import type { MarkOptional } from 'ts-essentials'
import type { FieldPermissions, User } from '../../auth/types.js'
import type { SanitizedFieldPermissions, User } from '../../auth/types.js'
import type { ClientBlock, ClientField, Field } from '../../fields/config/types.js'
import type { Payload } from '../../types/index.js'
import type {
@@ -79,7 +79,7 @@ export type ServerComponentProps = {
formState: FormState
i18n: I18nClient
payload: Payload
permissions: FieldPermissions
permissions: SanitizedFieldPermissions
siblingData: Data
user: User
}

View File

@@ -1,6 +1,6 @@
import { type SupportedLanguages } from '@payloadcms/translations'
import type { DocumentPermissions } from '../../auth/types.js'
import type { SanitizedDocumentPermissions } from '../../auth/types.js'
import type { Field, Validate } from '../../fields/config/types.js'
import type { TypedLocale } from '../../index.js'
import type { DocumentPreferences } from '../../preferences/types.js'
@@ -61,7 +61,7 @@ export type FormStateWithoutComponents = {
export type BuildFormStateArgs = {
data?: Data
docPermissions: DocumentPermissions | undefined
docPermissions: SanitizedDocumentPermissions | undefined
docPreferences: DocumentPreferences
fallbackLocale?: false | TypedLocale
formState?: FormState

View File

@@ -1,6 +1,6 @@
import type { ClientTranslationsObject } from '@payloadcms/translations'
import type { Permissions } from '../../auth/index.js'
import type { SanitizedPermissions } from '../../auth/index.js'
import type { ImportMap } from '../../bin/generateImportMap/index.js'
import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
import type { ClientConfig } from '../../config/client.js'
@@ -52,7 +52,7 @@ export type InitPageResult = {
globalConfig?: SanitizedGlobalConfig
languageOptions: LanguageOptions
locale?: Locale
permissions: Permissions
permissions: SanitizedPermissions
redirectTo?: string
req: PayloadRequest
translations: ClientTranslationsObject

View File

@@ -1,12 +1,15 @@
import type { AllOperations, PayloadRequest } from '../types/index.js'
import type { Permissions } from './types.js'
import type { Permissions, SanitizedPermissions } from './types.js'
import { getEntityPolicies } from '../utilities/getEntityPolicies.js'
import { sanitizePermissions } from '../utilities/sanitizePermissions.js'
type GetAccessResultsArgs = {
req: PayloadRequest
}
export async function getAccessResults({ req }: GetAccessResultsArgs): Promise<Permissions> {
export async function getAccessResults({
req,
}: GetAccessResultsArgs): Promise<SanitizedPermissions> {
const results = {} as Permissions
const { payload, user } = req
@@ -74,5 +77,5 @@ export async function getAccessResults({ req }: GetAccessResultsArgs): Promise<P
}),
)
return results
return sanitizePermissions(results)
}

View File

@@ -1,5 +1,5 @@
import type { PayloadRequest } from '../../types/index.js'
import type { Permissions } from '../types.js'
import type { SanitizedPermissions } from '../types.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { adminInit as adminInitTelemetry } from '../../utilities/telemetry/events/adminInit.js'
@@ -9,7 +9,7 @@ type Arguments = {
req: PayloadRequest
}
export const accessOperation = async (args: Arguments): Promise<Permissions> => {
export const accessOperation = async (args: Arguments): Promise<SanitizedPermissions> => {
const { req } = args
adminInitTelemetry(req)

View File

@@ -1,6 +1,5 @@
import type { TypedUser } from '../../index.js'
import type { SanitizedPermissions, TypedUser } from '../../index.js'
import type { PayloadRequest } from '../../types/index.js'
import type { Permissions } from '../types.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { executeAuthStrategies } from '../executeAuthStrategies.js'
@@ -12,7 +11,7 @@ export type AuthArgs = {
}
export type AuthResult = {
permissions: Permissions
permissions: SanitizedPermissions
responseHeaders?: Headers
user: null | TypedUser
}

View File

@@ -3,9 +3,12 @@ import type { DeepRequired } from 'ts-essentials'
import type { CollectionSlug, GlobalSlug, Payload } from '../index.js'
import type { PayloadRequest, Where } from '../types/index.js'
/**
* A permission object that can be used to determine if a user has access to a specific operation.
*/
export type Permission = {
permission: boolean
where?: Record<string, unknown>
where?: Where
}
export type FieldPermissions = {
@@ -30,6 +33,24 @@ export type FieldPermissions = {
}
}
export type SanitizedFieldPermissions =
| {
blocks?: {
[blockSlug: string]: {
fields: {
[fieldName: string]: SanitizedFieldPermissions
}
}
}
create: true
fields?: {
[fieldName: string]: SanitizedFieldPermissions
}
read: true
update: true
}
| true
export type CollectionPermission = {
create: Permission
delete: Permission
@@ -41,6 +62,19 @@ export type CollectionPermission = {
update: Permission
}
export type SanitizedCollectionPermission = {
create?: true
delete?: true
fields:
| {
[fieldName: string]: SanitizedFieldPermissions
}
| true
read?: true
readVersions?: true
update?: true
}
export type GlobalPermission = {
fields: {
[fieldName: string]: FieldPermissions
@@ -50,7 +84,21 @@ export type GlobalPermission = {
update: Permission
}
export type SanitizedGlobalPermission = {
fields:
| {
[fieldName: string]: SanitizedFieldPermissions
}
| true
read?: true
readVersions?: true
update?: true
}
export type DocumentPermissions = CollectionPermission | GlobalPermission
export type SanitizedDocumentPermissions = SanitizedCollectionPermission | SanitizedGlobalPermission
export type Permissions = {
canAccessAdmin: boolean
collections: {
@@ -61,6 +109,32 @@ export type Permissions = {
}
}
export type SanitizedPermissions = {
canAccessAdmin?: boolean
collections?: {
[collectionSlug: string]: {
create?: true
delete?: true
fields: {
[fieldName: string]: SanitizedFieldPermissions
}
read?: true
readVersions?: true
update?: true
}
}
globals?: {
[globalSlug: string]: {
fields: {
[fieldName: string]: SanitizedFieldPermissions
}
read?: true
readVersions?: true
update?: true
}
}
}
type BaseUser = {
collection: string
email?: string

View File

@@ -20,7 +20,7 @@ import type {
ServerSideEditViewProps,
VisibleEntities,
} from '../admin/views/types.js'
import type { Permissions } from '../auth/index.js'
import type { SanitizedPermissions } from '../auth/index.js'
import type {
AddToImportMap,
ImportMap,
@@ -398,7 +398,7 @@ export type ServerProps = {
readonly locale?: Locale
readonly params?: { [key: string]: string | string[] | undefined }
readonly payload: Payload
readonly permissions?: Permissions
readonly permissions?: SanitizedPermissions
readonly searchParams?: { [key: string]: string | string[] | undefined }
readonly user?: TypedUser
readonly visibleEntities?: VisibleEntities

View File

@@ -778,6 +778,7 @@ export { registerFirstUserOperation } from './auth/operations/registerFirstUser.
export { resetPasswordOperation } from './auth/operations/resetPassword.js'
export { unlockOperation } from './auth/operations/unlock.js'
export { verifyEmailOperation } from './auth/operations/verifyEmail.js'
export type {
AuthStrategyFunction,
AuthStrategyFunctionArgs,
@@ -788,9 +789,15 @@ export type {
IncomingAuthType,
Permission,
Permissions,
SanitizedCollectionPermission,
SanitizedDocumentPermissions,
SanitizedFieldPermissions,
SanitizedGlobalPermission,
SanitizedPermissions,
User,
VerifyConfig,
} from './auth/types.js'
export { generateImportMap } from './bin/generateImportMap/index.js'
export type { ImportMap } from './bin/generateImportMap/index.js'
@@ -1218,6 +1225,7 @@ export { isValidID } from './utilities/isValidID.js'
export { killTransaction } from './utilities/killTransaction.js'
export { mapAsync } from './utilities/mapAsync.js'
export { sanitizeFallbackLocale } from './utilities/sanitizeFallbackLocale.js'
export { recursivelySanitizePermissions as sanitizePermissions } from './utilities/sanitizePermissions.js'
export { traverseFields } from './utilities/traverseFields.js'
export type { TraverseFieldsCallback } from './utilities/traverseFields.js'
export { buildVersionCollectionFields } from './versions/buildCollectionFields.js'

View File

@@ -5,11 +5,15 @@ import { deleteHandler } from './requestHandlers/delete.js'
import { findByIDHandler } from './requestHandlers/findOne.js'
import { updateHandler } from './requestHandlers/update.js'
const preferenceAccess: Access = ({ req }) => ({
const preferenceAccess: Access = ({ req }) => {
if (!req.user) return false
return {
'user.value': {
equals: req?.user?.id,
},
})
}
}
const getPreferencesCollection = (config: Config): CollectionConfig => ({
slug: 'payload-preferences',

View File

@@ -6,7 +6,7 @@ import type { SanitizedGlobalConfig } from '../globals/config/types.js'
import type { AllOperations, Document, PayloadRequest, Where } from '../types/index.js'
import { combineQueries } from '../database/combineQueries.js'
import { tabHasName } from '../fields/config/types.js'
import { fieldAffectsData, tabHasName } from '../fields/config/types.js'
type Args = {
entity: SanitizedCollectionConfig | SanitizedGlobalConfig
@@ -132,6 +132,11 @@ export async function getEntityPolicies<T extends Args>(args: T): Promise<Return
}) => {
const mutablePolicies = policiesObj.fields
// 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) {
@@ -166,7 +171,7 @@ export async function getEntityPolicies<T extends Args>(args: T): Promise<Return
})
}
if ('blocks' in field && field?.blocks) {
if ('blocks' in field && field.blocks) {
if (!mutablePolicies[field.name]?.blocks) {
mutablePolicies[field.name].blocks = {}
}

View File

@@ -0,0 +1,450 @@
import type { CollectionPermission, Permissions } from '../auth/types.js'
import { recursivelySanitizePermissions, sanitizePermissions } from './sanitizePermissions.js'
/* eslint-disable perfectionist/sort-objects */
describe('recursivelySanitizePermissions', () => {
it('should sanitize a basic collection', () => {
const permissions: CollectionPermission = {
fields: {
text: {
create: {
permission: true,
},
read: {
permission: true,
},
update: {
permission: true,
},
},
},
create: {
permission: true,
},
read: {
permission: true,
},
update: {
permission: true,
},
delete: {
permission: false,
},
readVersions: {
permission: true,
},
}
recursivelySanitizePermissions(permissions)
expect(permissions).toStrictEqual({
fields: true,
create: true,
read: true,
update: true,
readVersions: true,
})
})
it('should sanitize a collection with where queries', () => {
const permissions: CollectionPermission = {
fields: {},
create: {
permission: true,
where: {
user: {
equals: 2,
},
},
},
read: {
permission: true,
},
update: {
permission: true,
},
delete: {
permission: false,
},
readVersions: {
permission: true,
where: {
user: {
equals: 1,
},
},
},
}
recursivelySanitizePermissions(permissions)
expect(permissions).toStrictEqual({
create: {
permission: true,
where: {
user: {
equals: 2,
},
},
},
read: true,
update: true,
readVersions: {
permission: true,
where: {
user: {
equals: 1,
},
},
},
})
})
it('should sanitize a collection with nested fields in blocks', () => {
const permissions: CollectionPermission = {
create: {
permission: true,
},
delete: {
permission: true,
},
read: {
permission: true,
},
update: {
permission: true,
},
fields: {
layout: {
create: {
permission: true,
},
blocks: {
blockWithTitle: {
fields: {
blockTitle: {
create: {
permission: true,
},
read: {
permission: true,
},
update: {
permission: true,
},
},
id: {
create: {
permission: true,
},
read: {
permission: true,
},
update: {
permission: true,
},
},
blockName: {
create: {
permission: true,
},
read: {
permission: true,
},
update: {
permission: true,
},
},
},
},
},
read: {
permission: true,
},
update: {
permission: true,
},
},
},
}
recursivelySanitizePermissions(permissions)
expect(permissions).toStrictEqual({
create: true,
delete: true,
fields: true,
read: true,
update: true,
})
})
it('should sanitize a collection with nested fields in blocks without truncating', () => {
const permissions: CollectionPermission = {
create: {
permission: true,
},
delete: {
permission: true,
},
read: {
permission: true,
},
update: {
permission: true,
},
fields: {
layout: {
create: {
permission: true,
},
blocks: {
blockWithTitle: {
fields: {
blockTitle: {
create: {
permission: true,
},
read: {
permission: true,
},
update: {
permission: true,
},
},
id: {
create: {
permission: true,
},
read: {
permission: true,
},
update: {
permission: true,
},
},
blockName: {
create: {
permission: false,
},
read: {
permission: true,
},
update: {
permission: false,
},
},
},
},
},
read: {
permission: true,
},
update: {
permission: true,
},
},
},
}
recursivelySanitizePermissions(permissions)
expect(permissions).toStrictEqual({
create: true,
delete: true,
read: true,
update: true,
fields: {
layout: {
create: true,
blocks: {
blockWithTitle: {
fields: {
blockTitle: true,
id: true,
blockName: {
read: true,
},
},
},
},
read: true,
update: true,
},
},
})
})
it('should sanitize a collection with nested fields in arrays', () => {
const permissions: Partial<CollectionPermission> = {
fields: {
arrayOfText: {
create: {
permission: true,
},
fields: {
text: {
create: {
permission: true,
},
read: {
permission: true,
},
update: {
permission: true,
},
},
hiddenText: {
create: {
permission: true,
},
read: {
permission: false,
},
update: {
permission: true,
},
},
id: {
create: {
permission: true,
},
read: {
permission: true,
},
update: {
permission: true,
},
},
},
read: {
permission: true,
},
update: {
permission: true,
},
},
},
}
recursivelySanitizePermissions(permissions)
expect(permissions).toStrictEqual({
fields: {
arrayOfText: {
create: true,
fields: {
text: true,
hiddenText: {
create: true,
update: true,
},
id: true,
},
read: true,
update: true,
},
},
})
})
it('should sanitize a collection with nested fields in richText', () => {
const permissions: Partial<CollectionPermission> = {
fields: {
text: {
create: {
permission: true,
},
read: {
permission: true,
},
update: {
permission: true,
},
},
richText: {
create: {
permission: true,
},
read: {
permission: true,
},
update: {
permission: true,
},
},
},
}
recursivelySanitizePermissions(permissions)
expect(permissions).toStrictEqual({
fields: true,
})
})
})
describe('sanitizePermissions', () => {
it('should return nothing for unauthenticated user', () => {
const permissions: Permissions = {
canAccessAdmin: false,
collections: {
'payload-preferences': {
fields: {
user: {
create: {
permission: false,
},
read: {
permission: false,
},
update: {
permission: false,
},
},
},
create: {
permission: false,
},
read: {
permission: false,
},
update: {
permission: false,
},
delete: {
permission: false,
},
},
},
globals: {
menu: {
fields: {
globalText: {
create: {
permission: false,
},
read: {
permission: false,
},
update: {
permission: false,
},
},
},
read: {
permission: false,
},
update: {
permission: false,
},
},
},
}
const sanitizedPermissions = sanitizePermissions(permissions)
expect(sanitizedPermissions).toStrictEqual({})
})
})

View File

@@ -0,0 +1,187 @@
import type { Permissions, SanitizedPermissions } from '../auth/types.js'
type PermissionObject = {
[key: string]: any
}
/**
* Check if all permissions in a FieldPermissions object are true on the condition that no nested blocks or fields are present.
*/
function areAllPermissionsTrue(data: PermissionObject): boolean {
if (data.blocks) {
for (const key in data.blocks) {
if (typeof data.blocks[key] === 'object') {
// If any recursive call returns false, the whole function returns false
if (key === 'fields' && !areAllPermissionsTrue(data.blocks[key].fields)) {
return false
}
if (data.blocks[key].fields && !areAllPermissionsTrue(data.blocks[key].fields)) {
return false
}
} else if (data.blocks[key] !== true) {
// If any value is not true, return false
return false
}
}
// If all values are true or it's an empty object, return true
return true
}
if (data.fields) {
for (const key in data.fields) {
if (typeof data.fields[key] === 'object') {
// If any recursive call returns false, the whole function returns false
if (!areAllPermissionsTrue(data.fields[key])) {
return false
}
} else if (data.fields[key] !== true) {
// If any value is not true, return false
return false
}
}
// If all values are true or it's an empty object, return true
return true
}
for (const key in data) {
if (typeof data[key] === 'object') {
// If any recursive call returns false, the whole function returns false
if (!areAllPermissionsTrue(data[key])) {
return false
}
} else if (data[key] !== true) {
// If any value is not true, return false
return false
}
}
// If all values are true or it's an empty object, return true
return true
}
/**
* Check if an object is a permission object.
*/
function isPermissionObject(data: unknown): boolean {
return typeof data === 'object' && 'permission' in data && typeof data['permission'] === 'boolean'
}
/**
* Recursively remove empty objects from an object.
*/
function cleanEmptyObjects(obj: any): void {
Object.keys(obj).forEach((key) => {
if (typeof obj[key] === 'object' && obj[key] !== null) {
// Recursive call
cleanEmptyObjects(obj[key])
if (Object.keys(obj[key]).length === 0) {
// Delete the key if the object is empty
delete obj[key]
}
} else if (obj[key] === null || obj[key] === undefined) {
delete obj[key]
}
})
}
/**
* Recursively resolve permissions in an object.
*/
export function recursivelySanitizePermissions(obj: PermissionObject): void {
if (typeof obj !== 'object') {
return
}
const entries = Object.entries(obj)
for (let i = 0; i < entries.length; i++) {
const [key, value] = entries[i]
// Check if it's a 'fields' key
if (key === 'fields') {
// Check if fields is empty
if (Object.keys(obj[key]).length === 0) {
delete obj[key]
continue
}
// Otherwise set fields to true if all permissions are true
else if (areAllPermissionsTrue(value)) {
obj[key] = true
continue
}
} else if (key === 'blocks') {
// Check if fields is empty
if (Object.keys(obj[key]).length === 0) {
delete obj[key]
continue
}
// Otherwise set fields to true if all permissions are true
else if (areAllPermissionsTrue(value)) {
obj[key] = true
continue
}
}
// Check if the whole object is a permission object
const isFullPermissionObject = Object.keys(value).every(
(subKey) =>
subKey !== 'blocks' &&
typeof value?.[subKey] === 'object' &&
'permission' in value[subKey] &&
!('where' in value[subKey]) &&
typeof value[subKey]['permission'] === 'boolean',
)
if (isFullPermissionObject) {
if (areAllPermissionsTrue(value)) {
obj[key] = true
continue
} else {
for (const subKey in value) {
if (value[subKey]['permission'] === true && !('where' in value[subKey])) {
value[subKey] = true
continue
} else if (value[subKey]['permission'] === true && 'where' in value[subKey]) {
// do nothing
} else {
delete value[subKey]
continue
}
}
}
} else if (isPermissionObject(value)) {
if (value['permission'] === true && !('where' in value)) {
// If the permission is true and there is no where clause, set the key to true
obj[key] = true
continue
} else if (value['permission'] === true && 'where' in value) {
// otherwise do nothing so we can keep the where clause
} else {
delete obj[key]
continue
}
} else {
recursivelySanitizePermissions(value)
}
}
}
/**
* Recursively remove empty objects and false values from an object.
*/
export function sanitizePermissions(data: Permissions): SanitizedPermissions {
if (data.canAccessAdmin === false) {
delete data.canAccessAdmin
}
if (data.collections) {
recursivelySanitizePermissions(data.collections)
}
if (data.globals) {
recursivelySanitizePermissions(data.globals)
}
// Run clean up of empty objects at the end
cleanEmptyObjects(data)
return data as unknown as SanitizedPermissions
}

View File

@@ -61,18 +61,15 @@ function removeUndefinedAndNullAndEmptyArraysRecursively(obj: object) {
* not the whole document.
*/
export const BlockContent: React.FC<Props> = (props) => {
const { baseClass, clientBlock, field, formSchema, Label, nodeKey, path, schemaPath } = props
const { baseClass, clientBlock, field, formSchema, Label, nodeKey } = props
let { formData } = props
const {
fieldProps: { permissions },
} = useEditorConfigContext()
const { i18n } = useTranslation()
const [editor] = useLexicalComposerContext()
// Used for saving collapsed to preferences (and gettin' it from there again)
// Remember, these preferences are scoped to the whole document, not just this form. This
// is important to consider for the data path used in setDocFieldPreferences
const { docPermissions, getDocPreferences, setDocFieldPreferences } = useDocumentInfo()
const { getDocPreferences, setDocFieldPreferences } = useDocumentInfo()
const [isCollapsed, setIsCollapsed] = React.useState<boolean>()
@@ -232,7 +229,7 @@ export const BlockContent: React.FC<Props> = (props) => {
parentIndexPath=""
parentPath={''}
parentSchemaPath=""
permissions={permissions} // TODO: Pass field permissions
permissions={true}
/>
</Collapsible>

View File

@@ -68,7 +68,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
id,
collectionSlug,
data: formData,
docPermissions,
docPermissions: { fields: true },
docPreferences: await getDocPreferences(),
globalSlug,
operation: 'update',
@@ -103,7 +103,6 @@ export const BlockComponent: React.FC<Props> = (props) => {
collectionSlug,
globalSlug,
getDocPreferences,
docPermissions,
// DO NOT ADD FORMDATA HERE! Adding formData will kick you out of sub block editors while writing.
])

View File

@@ -150,7 +150,7 @@ export const UploadDrawer: React.FC<{
parentIndexPath=""
parentPath=""
parentSchemaPath=""
permissions={{}}
permissions={docPermissions.fields}
readOnly={false}
/>
<FormSubmit>{t('fields:saveChanges')}</FormSubmit>

View File

@@ -96,11 +96,11 @@ export const AddNewRelation: React.FC<Props> = ({
useEffect(() => {
if (permissions) {
if (relatedCollections.length === 1) {
setShow(permissions.collections[relatedCollections[0]?.slug]?.create?.permission)
setShow(permissions.collections[relatedCollections[0]?.slug]?.create)
} else {
setShow(
relatedCollections.some(
(collection) => permissions.collections[collection?.slug]?.create?.permission,
(collection) => permissions.collections[collection?.slug]?.create,
),
)
}
@@ -186,7 +186,7 @@ export const AddNewRelation: React.FC<Props> = ({
render={({ close: closePopup }) => (
<PopupList.ButtonGroup>
{relatedCollections.map((relatedCollection) => {
if (permissions.collections[relatedCollection?.slug].create.permission) {
if (permissions.collections[relatedCollection?.slug].create) {
return (
<PopupList.Button
className={`${baseClass}__relation-button--${relatedCollection?.slug}`}
@@ -207,8 +207,7 @@ export const AddNewRelation: React.FC<Props> = ({
)}
size="medium"
/>
{collectionConfig &&
permissions.collections[collectionConfig?.slug]?.create?.permission && (
{collectionConfig && permissions.collections[collectionConfig?.slug]?.create && (
<DocumentDrawer onSave={onSave} />
)}
</Fragment>

View File

@@ -1,6 +1,6 @@
'use client'
import type { Data, DocumentPermissions, DocumentSlots, FormState } from 'payload'
import type { Data, DocumentSlots, FormState, SanitizedDocumentPermissions } from 'payload'
import { useModal } from '@faceless-ui/modal'
import * as qs from 'qs-esm'
@@ -26,7 +26,7 @@ type FormsManagerContext = {
readonly activeIndex: State['activeIndex']
readonly addFiles: (filelist: FileList) => Promise<void>
readonly collectionSlug: string
readonly docPermissions?: DocumentPermissions
readonly docPermissions?: SanitizedDocumentPermissions
readonly documentSlots: DocumentSlots
readonly forms: State['forms']
getFormDataRef: React.RefObject<() => Data>
@@ -91,7 +91,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
const [documentSlots, setDocumentSlots] = React.useState<DocumentSlots>({})
const [hasSubmitted, setHasSubmitted] = React.useState(false)
const [docPermissions, setDocPermissions] = React.useState<DocumentPermissions>()
const [docPermissions, setDocPermissions] = React.useState<SanitizedDocumentPermissions>()
const [hasSavePermission, setHasSavePermission] = React.useState(false)
const [hasPublishPermission, setHasPublishPermission] = React.useState(false)
const [hasInitializedState, setHasInitializedState] = React.useState(false)
@@ -162,7 +162,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
method: 'post',
})
const json: DocumentPermissions = await res.json()
const json: SanitizedDocumentPermissions = await res.json()
const publishedAccessJSON = await fetch(
`${serverURL}${api}${docAccessURL}?${qs.stringify(params)}`,
{
@@ -188,7 +188,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
}),
)
setHasPublishPermission(publishedAccessJSON?.update?.permission)
setHasPublishPermission(publishedAccessJSON?.update)
setHasInitializedDocPermissions(true)
}, [api, code, collectionSlug, i18n.language, serverURL])

View File

@@ -44,7 +44,7 @@ export const DeleteMany: React.FC<Props> = (props) => {
const { clearRouteCache } = useRouteCache()
const collectionPermissions = permissions?.collections?.[slug]
const hasDeletePermission = collectionPermissions?.delete?.permission
const hasDeletePermission = collectionPermissions?.delete
const modalSlug = `delete-${slug}`

View File

@@ -3,9 +3,9 @@ import type {
ClientCollectionConfig,
ClientGlobalConfig,
ClientUser,
CollectionPermission,
GlobalPermission,
SanitizedCollectionConfig,
SanitizedCollectionPermission,
SanitizedGlobalPermission,
} from 'payload'
import { getTranslation } from '@payloadcms/translations'
@@ -57,7 +57,7 @@ export const DocumentControls: React.FC<{
readonly onDuplicate?: DocumentDrawerContextType['onDuplicate']
readonly onSave?: DocumentDrawerContextType['onSave']
readonly onTakeOver?: () => void
readonly permissions: CollectionPermission | GlobalPermission | null
readonly permissions: null | SanitizedCollectionPermission | SanitizedGlobalPermission
readonly readOnlyForIncomingUser?: boolean
readonly redirectAfterDelete?: boolean
readonly redirectAfterDuplicate?: boolean
@@ -118,11 +118,9 @@ export const DocumentControls: React.FC<{
}
}, [data, i18n, dateFormat])
const hasCreatePermission =
permissions && 'create' in permissions && permissions.create?.permission
const hasCreatePermission = permissions && 'create' in permissions && permissions.create
const hasDeletePermission =
permissions && 'delete' in permissions && permissions.delete?.permission
const hasDeletePermission = permissions && 'delete' in permissions && permissions.delete
const showDotMenu = Boolean(
collectionConfig && id && !disableActions && (hasCreatePermission || hasDeletePermission),

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientField, DocumentPermissions } from 'payload'
import type { ClientField, SanitizedDocumentPermissions } from 'payload'
import { fieldIsSidebar } from 'payload/shared'
import React from 'react'
@@ -15,7 +15,7 @@ type Args = {
readonly AfterFields?: React.ReactNode
readonly BeforeFields?: React.ReactNode
readonly Description?: React.ReactNode
readonly docPermissions: DocumentPermissions
readonly docPermissions: SanitizedDocumentPermissions
readonly fields: ClientField[]
readonly forceSidebarWrap?: boolean
readonly readOnly?: boolean

View File

@@ -132,7 +132,7 @@ export const EditMany: React.FC<EditManyProps> = (props) => {
const { clearRouteCache } = useRouteCache()
const collectionPermissions = permissions?.collections?.[slug]
const hasUpdatePermission = collectionPermissions?.update?.permission
const hasUpdatePermission = collectionPermissions?.update
const drawerSlug = `edit-${slug}`
@@ -261,7 +261,7 @@ export const EditMany: React.FC<EditManyProps> = (props) => {
parentIndexPath=""
parentPath=""
parentSchemaPath={slug}
permissions={permissions?.collections?.[slug]?.fields}
permissions={collectionPermissions?.fields}
readOnly={false}
/>
)}

View File

@@ -1,10 +1,10 @@
'use client'
import type { TFunction } from '@payloadcms/translations'
import type { FieldPermissions, LoginWithUsernameOptions } from 'payload'
import type { LoginWithUsernameOptions, SanitizedFieldPermissions } from 'payload'
import { email, username } from 'payload/shared'
import React, { Fragment } from 'react'
import React from 'react'
import { EmailField } from '../../fields/Email/index.js'
import { TextField } from '../../fields/Text/index.js'
@@ -13,9 +13,11 @@ type RenderEmailAndUsernameFieldsProps = {
className?: string
loginWithUsername?: false | LoginWithUsernameOptions
operation?: 'create' | 'update'
permissions?: {
[fieldName: string]: FieldPermissions
permissions?:
| {
[fieldName: string]: SanitizedFieldPermissions
}
| true
readOnly: boolean
t: TFunction
}

View File

@@ -1,6 +1,6 @@
'use client'
import type { Permissions } from 'payload'
import type { SanitizedPermissions } from 'payload'
import { useEffect } from 'react'
@@ -14,8 +14,9 @@ import { useAuth } from '../../providers/Auth/index.js'
*/
type Props = {
permissions: Permissions
permissions: SanitizedPermissions
}
export function HydrateAuthProvider({ permissions }: Props) {
const { setPermissions } = useAuth()

View File

@@ -44,7 +44,7 @@ export const PublishMany: React.FC<PublishManyProps> = (props) => {
const { stringifyParams } = useSearchParams()
const collectionPermissions = permissions?.collections?.[slug]
const hasPermission = collectionPermissions?.update?.permission
const hasPermission = collectionPermissions?.update
const modalSlug = `publish-${slug}`

View File

@@ -172,8 +172,7 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
const preferenceKey = `${relationTo}-list`
const canCreate =
allowCreate !== false && permissions?.collections?.[relationTo]?.create?.permission
const canCreate = allowCreate !== false && permissions?.collections?.[relationTo]?.create
return (
<div className={baseClass}>

View File

@@ -138,7 +138,7 @@ export const Status: React.FC = () => {
],
)
const canUpdate = docPermissions?.update?.permission
const canUpdate = docPermissions?.update
if (statusToRender) {
return (

View File

@@ -45,7 +45,7 @@ export const UnpublishMany: React.FC<UnpublishManyProps> = (props) => {
const { clearRouteCache } = useRouteCache()
const collectionPermissions = permissions?.collections?.[slug]
const hasPermission = collectionPermissions?.update?.permission
const hasPermission = collectionPermissions?.update
const modalSlug = `unpublish-${slug}`

View File

@@ -206,9 +206,7 @@ export const Upload: React.FC<UploadProps> = (props) => {
}, [showUrlInput])
const canRemoveUpload =
docPermissions?.update?.permission &&
'delete' in docPermissions &&
docPermissions?.delete?.permission
docPermissions?.update && 'delete' in docPermissions && docPermissions?.delete
const hasImageSizes = uploadConfig?.imageSizes?.length > 0
const hasResizeOptions = Boolean(uploadConfig?.resizeOptions)

View File

@@ -1,5 +1,5 @@
'use client'
import type { ArrayField, ClientField, FieldPermissions, Row } from 'payload'
import type { ArrayField, ClientField, Row, SanitizedFieldPermissions } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import React from 'react'
@@ -30,7 +30,7 @@ type ArrayRowProps = {
readonly moveRow: (fromIndex: number, toIndex: number) => void
readonly parentPath: string
readonly path: string
readonly permissions: FieldPermissions
readonly permissions: SanitizedFieldPermissions
readonly readOnly?: boolean
readonly removeRow: (rowIndex: number) => void
readonly row: Row
@@ -144,7 +144,7 @@ export const ArrayRow: React.FC<ArrayRowProps> = ({
parentIndexPath=""
parentPath={path}
parentSchemaPath={schemaPath}
permissions={permissions?.fields}
permissions={permissions === true ? permissions : permissions?.fields}
readOnly={readOnly}
/>
</Collapsible>

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientBlock, ClientField, FieldPermissions, Labels, Row } from 'payload'
import type { ClientBlock, ClientField, Labels, Row, SanitizedFieldPermissions } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import React from 'react'
@@ -31,7 +31,7 @@ type BlocksFieldProps = {
moveRow: (fromIndex: number, toIndex: number) => void
parentPath: string
path: string
permissions: FieldPermissions
permissions: SanitizedFieldPermissions
readOnly: boolean
removeRow: (rowIndex: number) => void
row: Row
@@ -147,7 +147,9 @@ export const BlockRow: React.FC<BlocksFieldProps> = ({
parentIndexPath=""
parentPath={path}
parentSchemaPath={schemaPath}
permissions={permissions?.blocks?.[block.slug]?.fields}
permissions={
permissions === true ? permissions : permissions?.blocks?.[block.slug]?.fields
}
readOnly={readOnly}
/>
</Collapsible>

View File

@@ -104,7 +104,7 @@ export const GroupFieldComponent: GroupFieldClientComponent = (props) => {
parentIndexPath=""
parentPath={path}
parentSchemaPath={schemaPath}
permissions={permissions?.fields}
permissions={permissions === true ? permissions : permissions?.fields}
readOnly={readOnly}
/>
</div>

View File

@@ -31,7 +31,7 @@ export const MultiValueLabel: React.FC<
const { permissions } = useAuth()
const [showTooltip, setShowTooltip] = useState(false)
const { t } = useTranslation()
const hasReadPermission = Boolean(permissions?.collections?.[relationTo]?.read?.permission)
const hasReadPermission = Boolean(permissions?.collections?.[relationTo]?.read)
return (
<div className={baseClass}>

View File

@@ -32,7 +32,7 @@ export const SingleValue: React.FC<
const [showTooltip, setShowTooltip] = useState(false)
const { t } = useTranslation()
const { permissions } = useAuth()
const hasReadPermission = Boolean(permissions?.collections?.[relationTo]?.read?.permission)
const hasReadPermission = Boolean(permissions?.collections?.[relationTo]?.read)
return (
<SelectComponents.SingleValue {...props} className={baseClass}>

View File

@@ -3,7 +3,7 @@ import type {
ClientField,
ClientTab,
DocumentPreferences,
FieldPermissions,
SanitizedFieldPermissions,
StaticDescription,
TabsFieldClientComponent,
} from 'payload'
@@ -222,7 +222,7 @@ type ActiveTabProps = {
parentPath: string
parentSchemaPath: string
path: string
permissions: FieldPermissions
permissions: SanitizedFieldPermissions
readOnly: boolean
}
function ActiveTabContent({

View File

@@ -164,11 +164,9 @@ export function UploadInput(props: UploadInputProps) {
if (typeof activeRelationTo === 'string') {
if (permissions?.collections && permissions.collections?.[activeRelationTo]?.create) {
if (permissions.collections[activeRelationTo].create?.permission === true) {
return true
}
}
}
return false
}, [activeRelationTo, permissions, readOnly, allowCreate])

View File

@@ -1,6 +1,11 @@
'use client'
import type { ClientComponentProps, ClientField, FieldPaths, FieldPermissions } from 'payload'
import type {
ClientComponentProps,
ClientField,
FieldPaths,
SanitizedFieldPermissions,
} from 'payload'
import React from 'react'
@@ -31,7 +36,7 @@ import { useFormFields } from '../../forms/Form/index.js'
type RenderFieldProps = {
clientFieldConfig: ClientField
permissions: FieldPermissions
permissions: SanitizedFieldPermissions
} & FieldPaths &
Pick<ClientComponentProps, 'forceRender' | 'readOnly' | 'schemaPath'>

View File

@@ -1,7 +1,5 @@
'use client'
import type { FieldPermissions } from 'payload'
import { getFieldPaths } from 'payload/shared'
import React from 'react'
@@ -51,18 +49,17 @@ export const RenderFields: React.FC<Props> = (props) => {
return null
}
const fieldPermissions: FieldPermissions =
'name' in field ? permissions?.[field.name] : permissions
// If the user cannot read the field, then filter it out
// This is different from `admin.readOnly` which is executed based on `operation`
const lacksReadPermission =
fieldPermissions &&
'read' in fieldPermissions &&
'permission' in fieldPermissions.read &&
fieldPermissions?.read?.permission === false
const hasReadPermission =
permissions === true ||
('name' in field &&
typeof permissions === 'object' &&
permissions?.[field.name] &&
(permissions[field.name] === true ||
('read' in permissions[field.name] && permissions[field.name].read)))
if (lacksReadPermission) {
if ('name' in field && !hasReadPermission) {
return null
}
@@ -75,13 +72,15 @@ export const RenderFields: React.FC<Props> = (props) => {
}
// If the user does not have access control to begin with, force it to be read-only
const lacksOperationPermission =
fieldPermissions &&
operation in fieldPermissions &&
'permission' in fieldPermissions[operation] &&
fieldPermissions[operation]?.permission === false
const hasOperationPermission =
permissions === true ||
('name' in field &&
typeof permissions === 'object' &&
permissions?.[field.name] &&
(permissions[field.name] === true ||
(operation in permissions[field.name] && permissions[field.name][operation])))
if (lacksOperationPermission) {
if ('name' in field && !hasOperationPermission) {
isReadOnly = true
}
@@ -102,7 +101,13 @@ export const RenderFields: React.FC<Props> = (props) => {
parentPath={parentPath}
parentSchemaPath={parentSchemaPath}
path={path}
permissions={fieldPermissions}
permissions={
permissions === null || permissions === true
? true
: 'name' in field
? permissions?.[field.name]
: permissions
}
readOnly={isReadOnly}
schemaPath={schemaPath}
/>

View File

@@ -1,4 +1,4 @@
import type { ClientField, FieldPermissions } from 'payload'
import type { ClientField, SanitizedFieldPermissions } from 'payload'
export type Props = {
readonly className?: string
@@ -17,9 +17,8 @@ export type Props = {
readonly parentSchemaPath: string
readonly permissions:
| {
[fieldName: string]: FieldPermissions
[fieldName: string]: SanitizedFieldPermissions
}
| FieldPermissions
| null
| SanitizedFieldPermissions
readonly readOnly?: boolean
}

View File

@@ -1,6 +1,5 @@
import type {
Data,
DocumentPermissions,
DocumentPreferences,
Field,
FieldSchemaMap,
@@ -8,6 +7,7 @@ import type {
FormState,
FormStateWithoutComponents,
PayloadRequest,
SanitizedFieldPermissions,
} from 'payload'
import ObjectIdImport from 'bson-objectid'
@@ -61,7 +61,12 @@ export type AddFieldStatePromiseArgs = {
parentPath: string
parentSchemaPath: string
passesCondition: boolean
permissions: DocumentPermissions['fields']
permissions:
| {
[fieldName: string]: SanitizedFieldPermissions
}
| null
| SanitizedFieldPermissions
preferences: DocumentPreferences
previousFormState: FormState
renderAllFields: boolean
@@ -131,8 +136,9 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
const disabledFromAdmin = field?.admin && 'disabled' in field.admin && field.admin.disabled
if (fieldAffectsData(field) && !(isHiddenField || disabledFromAdmin)) {
let hasPermission =
typeof permissions?.[field.name]?.read === 'boolean' ? permissions[field.name].read : true
const fieldPermissions = permissions[field.name]
let hasPermission: boolean = fieldPermissions === true || fieldPermissions?.read
if (typeof field?.access?.read === 'function') {
hasPermission = await field.access.read({ doc: fullData, req, siblingData: data })
@@ -243,7 +249,8 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
parentPassesCondition: passesCondition,
parentPath,
parentSchemaPath: schemaPath,
permissions: permissions?.[field.name]?.fields || {},
permissions:
fieldPermissions === true ? fieldPermissions : fieldPermissions?.fields || {},
preferences,
previousFormState,
renderAllFields: requiresRender,

View File

@@ -1,12 +1,12 @@
import type {
Data,
DocumentPermissions,
DocumentPreferences,
Field,
FieldSchemaMap,
FormState,
FormStateWithoutComponents,
PayloadRequest,
SanitizedDocumentPermissions,
} from 'payload'
import type { RenderFieldMethod } from './types.js'
@@ -26,7 +26,7 @@ type Args = {
fieldSchemaMap: FieldSchemaMap | undefined
id?: number | string
operation?: 'create' | 'update'
permissions: DocumentPermissions['fields']
permissions: SanitizedDocumentPermissions['fields']
preferences: DocumentPreferences
/**
* Optionally accept the previous form state,

View File

@@ -1,12 +1,12 @@
import type {
Data,
DocumentPermissions,
DocumentPreferences,
Field as FieldSchema,
FieldSchemaMap,
FormState,
FormStateWithoutComponents,
PayloadRequest,
SanitizedFieldPermissions,
} from 'payload'
import type { AddFieldStatePromiseArgs } from './addFieldStatePromise.js'
@@ -47,7 +47,12 @@ type Args = {
parentPassesCondition?: boolean
parentPath: string
parentSchemaPath: string
permissions: DocumentPermissions['fields']
permissions:
| {
[fieldName: string]: SanitizedFieldPermissions
}
| null
| SanitizedFieldPermissions
preferences?: DocumentPreferences
previousFormState: FormState
renderAllFields: boolean

View File

@@ -2,8 +2,8 @@ import type {
ClientComponentProps,
ClientField,
FieldPaths,
FieldPermissions,
PayloadComponent,
SanitizedFieldPermissions,
ServerComponentProps,
} from 'payload'
@@ -42,15 +42,18 @@ export const renderField: RenderFieldMethod = ({
i18n: req.i18n,
})
const permissions = fieldAffectsData(fieldConfig)
const permissions =
incomingPermissions === true
? true
: fieldAffectsData(fieldConfig)
? incomingPermissions?.[fieldConfig.name]
: ({} as FieldPermissions)
: ({} as SanitizedFieldPermissions)
const clientProps: ClientComponentProps & Partial<FieldPaths> = {
customComponents: fieldState?.customComponents || {},
field: clientField,
path,
readOnly: permissions?.[operation]?.permission === false,
readOnly: permissions !== true && !permissions?.[operation],
schemaPath,
}

View File

@@ -1,12 +1,12 @@
import type {
Data,
DocumentPermissions,
Field,
FieldSchemaMap,
FieldState,
FormState,
Operation,
PayloadRequest,
SanitizedFieldPermissions,
} from 'payload'
export type RenderFieldArgs = {
@@ -20,7 +20,12 @@ export type RenderFieldArgs = {
parentPath: string
parentSchemaPath: string
path: string
permissions: DocumentPermissions['fields']
permissions:
| {
[fieldName: string]: SanitizedFieldPermissions
}
| null
| SanitizedFieldPermissions
previousFieldState: FieldState
req: PayloadRequest
schemaPath: string

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientUser, Permissions, User } from 'payload'
import type { ClientUser, SanitizedPermissions, User } from 'payload'
import { useModal } from '@faceless-ui/modal'
import { usePathname, useRouter } from 'next/navigation.js'
@@ -23,11 +23,11 @@ export type UserWithToken<T = ClientUser> = {
export type AuthContext<T = ClientUser> = {
fetchFullUser: () => Promise<null | User>
logOut: () => Promise<boolean>
permissions?: Permissions
permissions?: SanitizedPermissions
refreshCookie: (forceRefresh?: boolean) => void
refreshCookieAsync: () => Promise<ClientUser>
refreshPermissions: () => Promise<void>
setPermissions: (permissions: Permissions) => void
setPermissions: (permissions: SanitizedPermissions) => void
setUser: (user: null | UserWithToken<T>) => void
strategy?: string
token?: string
@@ -41,9 +41,10 @@ const maxTimeoutTime = 2147483647
type Props = {
children: React.ReactNode
permissions?: Permissions
permissions?: SanitizedPermissions
user?: ClientUser | null
}
export function AuthProvider({
children,
permissions: initialPermissions,
@@ -66,7 +67,7 @@ export function AuthProvider({
serverURL,
} = config
const [permissions, setPermissions] = useState<Permissions>(initialPermissions)
const [permissions, setPermissions] = useState<SanitizedPermissions>(initialPermissions)
const { i18n } = useTranslation()
const { closeAllModals, openModal } = useModal()
@@ -224,7 +225,7 @@ export function AuthProvider({
})
if (request.status === 200) {
const json: Permissions = await request.json()
const json: SanitizedPermissions = await request.json()
setPermissions(json)
} else {
throw new Error(`Fetching permissions failed with status code ${request.status}`)

View File

@@ -3,8 +3,8 @@ import type {
ClientCollectionConfig,
ClientGlobalConfig,
ClientUser,
DocumentPermissions,
DocumentPreferences,
SanitizedDocumentPermissions,
} from 'payload'
import * as qs from 'qs-esm'
@@ -51,7 +51,8 @@ const DocumentInfo: React.FC<
versionCount: versionCountFromProps,
} = props
const [docPermissions, setDocPermissions] = useState<DocumentPermissions>(docPermissionsFromProps)
const [docPermissions, setDocPermissions] =
useState<SanitizedDocumentPermissions>(docPermissionsFromProps)
const [hasSavePermission, setHasSavePermission] = useState<boolean>(hasSavePermissionFromProps)

View File

@@ -3,11 +3,11 @@ import type {
ClientGlobalConfig,
ClientUser,
Data,
DocumentPermissions,
DocumentPreferences,
FormState,
InsideFieldsPreferences,
SanitizedCollectionConfig,
SanitizedDocumentPermissions,
SanitizedGlobalConfig,
TypedUser,
} from 'payload'
@@ -24,7 +24,7 @@ export type DocumentInfoProps = {
readonly disableActions?: boolean
readonly disableCreate?: boolean
readonly disableLeaveWithoutSaving?: boolean
readonly docPermissions?: DocumentPermissions
readonly docPermissions?: SanitizedDocumentPermissions
readonly globalSlug?: SanitizedGlobalConfig['slug']
readonly hasPublishedDoc: boolean
readonly hasPublishPermission?: boolean

View File

@@ -1,4 +1,4 @@
import type { Data, DocumentPermissions, Permissions } from 'payload'
import type { Data, SanitizedDocumentPermissions, SanitizedPermissions } from 'payload'
import * as qs from 'qs-esm'
import React from 'react'
@@ -25,9 +25,9 @@ export const useGetDocPermissions = ({
i18n: any
id: string
locale: string
permissions: Permissions
permissions: SanitizedPermissions
serverURL: string
setDocPermissions: React.Dispatch<React.SetStateAction<DocumentPermissions>>
setDocPermissions: React.Dispatch<React.SetStateAction<SanitizedDocumentPermissions>>
setHasPublishPermission: React.Dispatch<React.SetStateAction<boolean>>
setHasSavePermission: React.Dispatch<React.SetStateAction<boolean>>
}) =>
@@ -61,7 +61,7 @@ export const useGetDocPermissions = ({
method: 'post',
})
const json: DocumentPermissions = await res.json()
const json: SanitizedDocumentPermissions = await res.json()
const publishedAccessJSON = await fetch(
`${serverURL}${api}${docAccessURL}?${qs.stringify(params)}`,
@@ -90,7 +90,7 @@ export const useGetDocPermissions = ({
}),
)
setHasPublishPermission(publishedAccessJSON?.update?.permission)
setHasPublishPermission(publishedAccessJSON?.update)
}
} else {
// when creating new documents, there is no permissions saved for this document yet

View File

@@ -3,7 +3,7 @@ import type { I18nClient, Language } from '@payloadcms/translations'
import type {
ClientConfig,
LanguageOptions,
Permissions,
SanitizedPermissions,
ServerFunctionClient,
User,
} from 'payload'
@@ -41,7 +41,7 @@ type Props = {
readonly isNavOpen?: boolean
readonly languageCode: string
readonly languageOptions: LanguageOptions
readonly permissions: Permissions
readonly permissions: SanitizedPermissions
readonly serverFunction: ServerFunctionClient
readonly switchLanguageServerAction?: (lang: string) => Promise<void>
readonly theme: Theme

View File

@@ -1,8 +1,8 @@
import type { I18nClient } from '@payloadcms/translations'
import type {
Permissions,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
SanitizedPermissions,
StaticLabel,
} from 'payload'
@@ -34,15 +34,12 @@ export type NavGroupType = {
export function groupNavItems(
entities: EntityToGroup[],
permissions: Permissions,
permissions: SanitizedPermissions,
i18n: I18nClient,
): NavGroupType[] {
const result = entities.reduce(
(groups, entityToGroup) => {
if (
permissions?.[entityToGroup.type.toLowerCase()]?.[entityToGroup.entity.slug]?.read
.permission
) {
if (permissions?.[entityToGroup.type.toLowerCase()]?.[entityToGroup.entity.slug]?.read) {
const translatedGroup = getTranslation(entityToGroup.entity.admin.group, i18n)
if (entityToGroup.entity.admin.group) {

View File

@@ -1,11 +1,15 @@
import type { CollectionPermission, DocumentPermissions, GlobalPermission } from 'payload'
import type {
SanitizedCollectionPermission,
SanitizedDocumentPermissions,
SanitizedGlobalPermission,
} from 'payload'
export const hasSavePermission = (args: {
/*
* Pass either `collectionSlug` or `globalSlug`
*/
collectionSlug?: string
docPermissions: DocumentPermissions
docPermissions: SanitizedDocumentPermissions
/*
* Pass either `collectionSlug` or `globalSlug`
*/
@@ -16,13 +20,13 @@ export const hasSavePermission = (args: {
if (collectionSlug) {
return Boolean(
(isEditing && docPermissions?.update?.permission) ||
(!isEditing && (docPermissions as CollectionPermission)?.create?.permission),
(isEditing && docPermissions?.update) ||
(!isEditing && (docPermissions as SanitizedCollectionPermission)?.create),
)
}
if (globalSlug) {
return Boolean((docPermissions as GlobalPermission)?.update?.permission)
return Boolean((docPermissions as SanitizedGlobalPermission)?.update)
}
return false

View File

@@ -57,24 +57,25 @@ export const Auth: React.FC<Props> = (props) => {
const collection = permissions?.collections?.[collectionSlug]
if (collection) {
const unlock = 'unlock' in collection ? collection.unlock : undefined
if (unlock) {
// current types for permissions do not include auth permissions, this will be fixed in another branch soon, for now we need to ignore the types
// @todo: fix types
// @ts-expect-error
return unlock.permission
}
return Boolean('unlock' in collection ? collection.unlock : undefined)
}
return false
}, [permissions, collectionSlug])
const apiKeyReadOnly = readOnly || !docPermissions?.fields?.apiKey?.update?.permission
const enableAPIKeyReadOnly = readOnly || !docPermissions?.fields?.enableAPIKey?.update?.permission
const apiKeyPermissions =
docPermissions?.fields === true ? true : docPermissions?.fields?.enableAPIKey
const canReadApiKey = docPermissions?.fields?.apiKey?.read?.permission
const canReadEnableAPIKey = docPermissions?.fields?.enableAPIKey?.read?.permission
const apiKeyReadOnly =
readOnly ||
apiKeyPermissions === true ||
(apiKeyPermissions && typeof apiKeyPermissions === 'object' && !apiKeyPermissions?.update)
const enableAPIKeyReadOnly =
readOnly || (apiKeyPermissions !== true && !apiKeyPermissions?.update)
const canReadApiKey = apiKeyPermissions === true || apiKeyPermissions?.read
const canReadEnableAPIKey = apiKeyPermissions === true || apiKeyPermissions?.read
const handleChangePassword = useCallback(
(showPasswordFields: boolean) => {

View File

@@ -3,8 +3,8 @@ import type {
AdminViewProps,
Locale,
Payload,
Permissions,
SanitizedCollectionConfig,
SanitizedPermissions,
User,
} from 'payload'
@@ -38,7 +38,7 @@ export type ListComponentServerProps = {
locale: Locale
params: AdminViewProps['params']
payload: Payload
permissions: Permissions
permissions: SanitizedPermissions
searchParams: AdminViewProps['searchParams']
user: User
}