Skip to content

Commit

Permalink
feat: sanitise access endpoint (#7335)
Browse files Browse the repository at this point in the history
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 <[email protected]>
Co-authored-by: Jacob Fletcher <[email protected]>
Co-authored-by: James <[email protected]>
  • Loading branch information
4 people authored Nov 15, 2024
1 parent 0b9d5a5 commit 26ffbca
Show file tree
Hide file tree
Showing 72 changed files with 1,000 additions and 230 deletions.
4 changes: 2 additions & 2 deletions packages/next/src/elements/DocumentHeader/Tabs/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down
5 changes: 2 additions & 3 deletions packages/next/src/elements/DocumentHeader/Tabs/tabs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
4 changes: 2 additions & 2 deletions packages/next/src/elements/DocumentHeader/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions packages/next/src/utilities/initReq.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -11,7 +11,7 @@ import { getRequestLanguage } from './getRequestLanguage.js'

type Result = {
i18n: I18nClient
permissions: Permissions
permissions: SanitizedPermissions
req: PayloadRequest
user: User
}
Expand Down
6 changes: 3 additions & 3 deletions packages/next/src/views/CreateFirstUser/index.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
import type { FormProps, UserWithToken } from '@payloadcms/ui'
import type {
ClientCollectionConfig,
DocumentPermissions,
DocumentPreferences,
FormState,
LoginWithUsernameOptions,
SanitizedDocumentPermissions,
} from 'payload'

import {
Expand All @@ -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
Expand Down Expand Up @@ -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>
Expand Down
6 changes: 3 additions & 3 deletions packages/next/src/views/Dashboard/Default/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -19,7 +19,7 @@ export type DashboardProps = {
}>
Link: React.ComponentType<any>
navGroups?: ReturnType<typeof groupNavItems>
permissions: Permissions
permissions: SanitizedPermissions
visibleEntities: VisibleEntities
} & ServerProps

Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 2 additions & 3 deletions packages/next/src/views/Dashboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions packages/next/src/views/Document/getDocumentPermissions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import type {
DocumentPermissions,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedDocumentPermissions,
SanitizedGlobalConfig,
} from 'payload'

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
Expand All @@ -19,7 +20,7 @@ export const getDocumentPermissions = async (args: {
id?: number | string
req: PayloadRequest
}): Promise<{
docPermissions: DocumentPermissions
docPermissions: SanitizedDocumentPermissions
hasPublishPermission: boolean
hasSavePermission: boolean
}> => {
Expand Down Expand Up @@ -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,
Expand All @@ -103,7 +108,7 @@ export const getDocumentPermissions = async (args: {
})

return {
docPermissions,
docPermissions: sanitizedDocPermissions,
hasPublishPermission,
hasSavePermission,
}
Expand Down
6 changes: 3 additions & 3 deletions packages/next/src/views/Document/getVersions.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 11 additions & 15 deletions packages/next/src/views/Document/getViewsFromConfig.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type {
AdminViewProps,
CollectionPermission,
GlobalPermission,
PayloadComponent,
SanitizedCollectionConfig,
SanitizedCollectionPermission,
SanitizedConfig,
SanitizedGlobalConfig,
SanitizedGlobalPermission,
ServerSideEditViewProps,
} from 'payload'
import type React from 'react'
Expand Down Expand Up @@ -38,7 +38,7 @@ export const getViewsFromConfig = ({
routeSegments: string[]
} & (
| {
docPermissions: CollectionPermission | GlobalPermission
docPermissions: SanitizedCollectionPermission | SanitizedGlobalPermission
overrideDocPermissions?: false | undefined
}
| {
Expand Down Expand Up @@ -78,19 +78,15 @@ 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`
switch (routeSegments.length) {
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'),
}
Expand Down Expand Up @@ -176,7 +172,7 @@ export const getViewsFromConfig = ({
}

case 'versions': {
if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
if (!overrideDocPermissions && docPermissions?.readVersions) {
CustomView = {
ComponentConfig: getCustomViewByKey(views, 'versions'),
}
Expand Down Expand Up @@ -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'),
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -323,7 +319,7 @@ export const getViewsFromConfig = ({
}

case 'versions': {
if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
if (!overrideDocPermissions && docPermissions?.readVersions) {
CustomView = {
ComponentConfig: getCustomViewByKey(views, 'versions'),
}
Expand All @@ -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('/')
Expand Down Expand Up @@ -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'),
}
Expand Down
4 changes: 2 additions & 2 deletions packages/next/src/views/Document/renderDocumentSlots.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type {
DefaultServerFunctionArgs,
DocumentPermissions,
DocumentSlots,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedDocumentPermissions,
SanitizedGlobalConfig,
StaticDescription,
} from 'payload'
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/next/src/views/List/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}

Expand Down Expand Up @@ -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`,
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/views/Version/Default/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
9 changes: 7 additions & 2 deletions packages/next/src/views/Version/Default/types.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Loading

0 comments on commit 26ffbca

Please sign in to comment.