fix(next): admin access control (#5887)

This commit is contained in:
Jacob Fletcher
2024-04-17 10:31:39 -04:00
committed by GitHub
parent abf0461d80
commit 6cd5b253f1
8 changed files with 108 additions and 15 deletions

View File

@@ -25,6 +25,8 @@ type Args = {
searchParams: { [key: string]: string | string[] | undefined } searchParams: { [key: string]: string | string[] | undefined }
} }
const authRoutes = ['/login', '/logout', '/create-first-user', '/forgot', '/reset', '/verify']
export const initPage = async ({ export const initPage = async ({
config: configPromise, config: configPromise,
redirectUnauthenticatedUser = false, redirectUnauthenticatedUser = false,
@@ -79,13 +81,19 @@ export const initPage = async ({
.filter(Boolean), .filter(Boolean),
} }
const routeSegments = route.replace(payload.config.routes.admin, '').split('/').filter(Boolean) const {
routes: { admin: adminRoute },
} = payload.config
const routeSegments = route.replace(adminRoute, '').split('/').filter(Boolean)
const [entityType, entitySlug, createOrID] = routeSegments const [entityType, entitySlug, createOrID] = routeSegments
const collectionSlug = entityType === 'collections' ? entitySlug : undefined const collectionSlug = entityType === 'collections' ? entitySlug : undefined
const globalSlug = entityType === 'globals' ? entitySlug : undefined const globalSlug = entityType === 'globals' ? entitySlug : undefined
const docID = collectionSlug && createOrID !== 'create' ? createOrID : undefined const docID = collectionSlug && createOrID !== 'create' ? createOrID : undefined
if (redirectUnauthenticatedUser && !user && route !== '/login') { const isAuthRoute = authRoutes.some((r) => r === route.replace(adminRoute, ''))
if (redirectUnauthenticatedUser && !user && !isAuthRoute) {
if (searchParams && 'redirect' in searchParams) delete searchParams.redirect if (searchParams && 'redirect' in searchParams) delete searchParams.redirect
const stringifiedSearchParams = Object.keys(searchParams ?? {}).length const stringifiedSearchParams = Object.keys(searchParams ?? {}).length
@@ -95,6 +103,10 @@ export const initPage = async ({
redirect(`${routes.admin}/login?redirect=${route + stringifiedSearchParams}`) redirect(`${routes.admin}/login?redirect=${route + stringifiedSearchParams}`)
} }
if (!permissions.canAccessAdmin && !isAuthRoute) {
notFound()
}
let collectionConfig: SanitizedCollectionConfig let collectionConfig: SanitizedCollectionConfig
let globalConfig: SanitizedGlobalConfig let globalConfig: SanitizedGlobalConfig

View File

@@ -8,6 +8,7 @@ import {
firstArrayText, firstArrayText,
hiddenAccessSlug, hiddenAccessSlug,
hiddenFieldsSlug, hiddenFieldsSlug,
noAdminAccessEmail,
readOnlySlug, readOnlySlug,
relyOnRequestHeadersSlug, relyOnRequestHeadersSlug,
restrictedSlug, restrictedSlug,
@@ -41,6 +42,7 @@ const UseRequestHeadersAccess: FieldAccess = ({ req: { headers } }) => {
export default buildConfigWithDefaults({ export default buildConfigWithDefaults({
admin: { admin: {
user: 'users', user: 'users',
autoLogin: false,
}, },
globals: [ globals: [
{ {
@@ -76,12 +78,17 @@ export default buildConfigWithDefaults({
slug: 'users', slug: 'users',
auth: true, auth: true,
access: { access: {
// admin: () => true, // admin: () => true,
admin: async () => admin: async ({ req }) => {
new Promise((resolve) => { if (req.user?.email === noAdminAccessEmail) {
return false
}
return new Promise((resolve) => {
// Simulate a request to an external service to determine access, i.e. another instance of Payload // 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 setTimeout(resolve, 50, true) // set to 'true' or 'false' here to simulate the response
}), })
},
}, },
fields: [ fields: [
{ {
@@ -431,6 +438,14 @@ export default buildConfigWithDefaults({
}, },
}) })
await payload.create({
collection: 'users',
data: {
email: noAdminAccessEmail,
password: 'test',
},
})
await payload.create({ await payload.create({
collection: slug, collection: slug,
data: { data: {

View File

@@ -1,8 +1,10 @@
import type { Page } from '@playwright/test' import type { Page } from '@playwright/test'
import type { Payload, TypeWithID } from 'payload/types' import type { TypeWithID } from 'payload/types'
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import { devUser } from 'credentials.js'
import path from 'path' import path from 'path'
import { wait } from 'payload/utilities'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../helpers/sdk/index.js' import type { PayloadTestSDK } from '../helpers/sdk/index.js'
@@ -13,6 +15,7 @@ import {
ensureAutoLoginAndCompilationIsDone, ensureAutoLoginAndCompilationIsDone,
exactText, exactText,
initPageConsoleErrorCatch, initPageConsoleErrorCatch,
login,
openDocControls, openDocControls,
openNav, openNav,
saveDocAndAssert, saveDocAndAssert,
@@ -22,6 +25,7 @@ import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { POLL_TOPASS_TIMEOUT } from '../playwright.config.js' import { POLL_TOPASS_TIMEOUT } from '../playwright.config.js'
import { import {
docLevelAccessSlug, docLevelAccessSlug,
noAdminAccessEmail,
readOnlySlug, readOnlySlug,
restrictedSlug, restrictedSlug,
restrictedVersionsSlug, restrictedVersionsSlug,
@@ -61,7 +65,8 @@ describe('access control', () => {
const context = await browser.newContext() const context = await browser.newContext()
page = await context.newPage() page = await context.newPage()
initPageConsoleErrorCatch(page) initPageConsoleErrorCatch(page)
await ensureAutoLoginAndCompilationIsDone({ page, serverURL })
await login({ page, serverURL })
}) })
test('field without read access should not show', async () => { test('field without read access should not show', async () => {
@@ -328,6 +333,28 @@ describe('access control', () => {
// ensure user is allowed to edit this document // ensure user is allowed to edit this document
await expect(documentDrawer2.locator('#field-name')).toBeEnabled() await expect(documentDrawer2.locator('#field-name')).toBeEnabled()
}) })
test('should completely block admin access', async () => {
const adminURL = `${serverURL}/admin`
await page.goto(adminURL)
await page.waitForURL(adminURL)
await expect(page.locator('.dashboard')).toBeVisible()
await page.goto(`${serverURL}/admin/logout`)
await page.waitForURL(`${serverURL}/admin/logout`)
await login({
page,
serverURL,
data: {
email: noAdminAccessEmail,
password: 'test',
},
})
await expect(page.locator('.next-error-h1')).toBeVisible()
})
}) })
// eslint-disable-next-line @typescript-eslint/require-await // eslint-disable-next-line @typescript-eslint/require-await

View File

@@ -14,3 +14,5 @@ export const docLevelAccessSlug = 'doc-level-access'
export const hiddenFieldsSlug = 'hidden-fields' export const hiddenFieldsSlug = 'hidden-fields'
export const hiddenAccessSlug = 'hidden-access' export const hiddenAccessSlug = 'hidden-access'
export const noAdminAccessEmail = 'no-admin-access@payloadcms.com'

View File

@@ -13,6 +13,10 @@ type FirstRegisterArgs = {
} }
type LoginArgs = { type LoginArgs = {
data?: {
email: string
password: string
}
page: Page page: Page
serverURL: string serverURL: string
} }
@@ -91,14 +95,24 @@ export async function firstRegister(args: FirstRegisterArgs): Promise<void> {
} }
export async function login(args: LoginArgs): Promise<void> { export async function login(args: LoginArgs): Promise<void> {
const { page, serverURL } = args const { page, serverURL, data = devUser } = args
await page.goto(`${serverURL}/admin`) await page.goto(`${serverURL}/admin/login`)
await page.fill('#field-email', devUser.email) await page.waitForURL(`${serverURL}/admin/login`)
await page.fill('#field-password', devUser.password) await wait(500)
await page.fill('#field-email', data.email)
await page.fill('#field-password', data.password)
await wait(500) await wait(500)
await page.click('[type=submit]') await page.click('[type=submit]')
await page.waitForURL(`${serverURL}/admin`) await page.waitForURL(`${serverURL}/admin`)
await expect(() => expect(page.url()).not.toContain(`/admin/login`)).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await expect(() => expect(page.url()).not.toContain(`/admin/create-first-user`)).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
} }
export async function saveDocHotkeyAndAssert(page: Page): Promise<void> { export async function saveDocHotkeyAndAssert(page: Page): Promise<void> {

View File

@@ -7,6 +7,7 @@ import type {
FetchOptions, FetchOptions,
FindArgs, FindArgs,
GeneratedTypes, GeneratedTypes,
LoginArgs,
UpdateArgs, UpdateArgs,
UpdateGlobalArgs, UpdateGlobalArgs,
} from './types.js' } from './types.js'
@@ -70,6 +71,17 @@ export class PayloadTestSDK<TGeneratedTypes extends GeneratedTypes<TGeneratedTyp
}) })
} }
login = async <T extends keyof TGeneratedTypes['collections']>({
jwt,
...args
}: LoginArgs<TGeneratedTypes, T>) => {
return this.fetch<TGeneratedTypes['collections'][T]>({
operation: 'login',
args,
jwt,
})
}
sendEmail = async ({ jwt, ...args }: { jwt?: string } & SendMailOptions): Promise<unknown> => { sendEmail = async ({ jwt, ...args }: { jwt?: string } & SendMailOptions): Promise<unknown> => {
return this.fetch({ return this.fetch({
operation: 'sendEmail', operation: 'sendEmail',

View File

@@ -25,7 +25,7 @@ export type GeneratedTypes<T extends BaseTypes> = {
export type FetchOptions = { export type FetchOptions = {
args?: Record<string, unknown> args?: Record<string, unknown>
jwt?: string jwt?: string
operation: 'create' | 'delete' | 'find' | 'sendEmail' | 'update' | 'updateGlobal' operation: 'create' | 'delete' | 'find' | 'login' | 'sendEmail' | 'update' | 'updateGlobal'
reduceJSON?: <R>(json: any) => R reduceJSON?: <R>(json: any) => R
} }
@@ -122,6 +122,17 @@ export type FindArgs<
where?: Where where?: Where
} & BaseArgs } & BaseArgs
export type LoginArgs<
TGeneratedTypes extends GeneratedTypes<TGeneratedTypes>,
TSlug extends keyof TGeneratedTypes['collections'],
> = {
collection: TSlug
data: {
email: string
password: string
}
} & BaseArgs
export type DeleteArgs< export type DeleteArgs<
TGeneratedTypes extends GeneratedTypes<TGeneratedTypes>, TGeneratedTypes extends GeneratedTypes<TGeneratedTypes>,
TSlug extends keyof TGeneratedTypes['collections'], TSlug extends keyof TGeneratedTypes['collections'],

View File

@@ -37,7 +37,7 @@
], ],
"paths": { "paths": {
"@payload-config": [ "@payload-config": [
"./test/_community/config.ts" "./test/access-control/config.ts"
], ],
"@payloadcms/live-preview": [ "@payloadcms/live-preview": [
"./packages/live-preview/src" "./packages/live-preview/src"
@@ -161,4 +161,4 @@
".next/types/**/*.ts", ".next/types/**/*.ts",
"scripts/**/*.ts" "scripts/**/*.ts"
] ]
} }