Skip to content

Commit f63e34e

Browse files
authored
fix: unlock access not being correctly denied when using a where query (#14585)
Fixes #14459 We were previously not throwing an error from the endpoint if validation failed for unlock and the UI was using permissions from the wrong hook, I've added a jsdoc to explain the difference between permissions form useAuth and useDocumentInfo
1 parent 43dcf84 commit f63e34e

File tree

6 files changed

+91
-20
lines changed

6 files changed

+91
-20
lines changed

packages/payload/src/auth/executeAccess.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,20 @@ export const executeAccess = async (
1515
access: Access,
1616
): Promise<AccessResult> => {
1717
if (access) {
18-
const result = await access({
18+
const resolvedConstraint = await access({
1919
id,
2020
data,
2121
isReadingStaticFile,
2222
req,
2323
})
2424

25-
if (!result) {
25+
if (!resolvedConstraint) {
2626
if (!disableErrors) {
2727
throw new Forbidden(req.t)
2828
}
2929
}
3030

31-
return result
31+
return resolvedConstraint
3232
}
3333

3434
if (req.user) {

packages/payload/src/auth/operations/unlock.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { CollectionSlug } from '../../index.js'
88
import type { PayloadRequest, Where } from '../../types/index.js'
99

1010
import { APIError } from '../../errors/index.js'
11-
import { Forbidden } from '../../index.js'
11+
import { combineQueries, Forbidden } from '../../index.js'
1212
import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js'
1313
import { commitTransaction } from '../../utilities/commitTransaction.js'
1414
import { initTransaction } from '../../utilities/initTransaction.js'
@@ -58,33 +58,36 @@ export const unlockOperation = async <TSlug extends CollectionSlug>(
5858

5959
try {
6060
const shouldCommit = await initTransaction(req)
61+
let whereConstraint: Where = {}
6162

6263
// /////////////////////////////////////
6364
// Access
6465
// /////////////////////////////////////
6566

6667
if (!overrideAccess) {
67-
await executeAccess({ req }, collectionConfig.access.unlock)
68+
const accessResult = await executeAccess({ req }, collectionConfig.access.unlock)
69+
70+
if (accessResult && typeof accessResult === 'object') {
71+
whereConstraint = accessResult
72+
}
6873
}
6974

7075
// /////////////////////////////////////
7176
// Unlock
7277
// /////////////////////////////////////
7378

74-
let whereConstraint: Where = {}
75-
7679
if (canLoginWithEmail && sanitizedEmail) {
77-
whereConstraint = {
80+
whereConstraint = combineQueries(whereConstraint, {
7881
email: {
7982
equals: sanitizedEmail,
8083
},
81-
}
84+
})
8285
} else if (canLoginWithUsername && sanitizedUsername) {
83-
whereConstraint = {
86+
whereConstraint = combineQueries(whereConstraint, {
8487
username: {
8588
equals: sanitizedUsername,
8689
},
87-
}
90+
})
8891
}
8992

9093
// Exclude trashed users unless `trash: true`
@@ -113,13 +116,14 @@ export const unlockOperation = async <TSlug extends CollectionSlug>(
113116
result = true
114117
} else {
115118
result = null
119+
throw new Forbidden(req.t)
116120
}
117121

118122
if (shouldCommit) {
119123
await commitTransaction(req)
120124
}
121125

122-
return result!
126+
return result
123127
} catch (error: unknown) {
124128
await killTransaction(req)
125129
throw error

packages/ui/src/providers/Auth/index.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,40 @@ export type UserWithToken<T = ClientUser> = {
2525
export type AuthContext<T = ClientUser> = {
2626
fetchFullUser: () => Promise<null | TypedUser>
2727
logOut: () => Promise<boolean>
28+
/**
29+
* These are the permissions for the current user from a global scope.
30+
*
31+
* When checking for permissions on document specific level, use the `useDocumentInfo` hook instead.
32+
*
33+
* @example
34+
*
35+
* ```tsx
36+
* import { useAuth } from 'payload/ui'
37+
*
38+
* const MyComponent: React.FC = () => {
39+
* const { permissions } = useAuth()
40+
*
41+
* if (permissions?.collections?.myCollection?.create) {
42+
* // user can create documents in 'myCollection'
43+
* }
44+
*
45+
* return null
46+
* }
47+
* ```
48+
*
49+
* with useDocumentInfo:
50+
*
51+
* ```tsx
52+
* import { useDocumentInfo } from 'payload/ui'
53+
*
54+
* const MyComponent: React.FC = () => {
55+
* const { docPermissions } = useDocumentInfo()
56+
* if (docPermissions?.create) {
57+
* // user can create this document
58+
* }
59+
* return null
60+
* } ```
61+
*/
2862
permissions?: SanitizedPermissions
2963
refreshCookie: (forceRefresh?: boolean) => void
3064
refreshCookieAsync: () => Promise<ClientUser>

packages/ui/src/views/Edit/Auth/index.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import { CheckboxField } from '../../../fields/Checkbox/index.js'
1414
import { ConfirmPasswordField } from '../../../fields/ConfirmPassword/index.js'
1515
import { PasswordField } from '../../../fields/Password/index.js'
1616
import { useFormFields, useFormModified } from '../../../forms/Form/context.js'
17-
import { useAuth } from '../../../providers/Auth/index.js'
1817
import { useConfig } from '../../../providers/Config/index.js'
1918
import { useDocumentInfo } from '../../../providers/DocumentInfo/index.js'
2019
import { useTranslation } from '../../../providers/Translation/index.js'
@@ -39,7 +38,6 @@ export const Auth: React.FC<Props> = (props) => {
3938
verify,
4039
} = props
4140

42-
const { permissions } = useAuth()
4341
const [changingPassword, setChangingPassword] = useState(requirePassword)
4442
const enableAPIKey = useFormFields(([fields]) => (fields && fields?.enableAPIKey) || null)
4543
const dispatchFields = useFormFields((reducer) => reducer[1])
@@ -131,14 +129,12 @@ export const Auth: React.FC<Props> = (props) => {
131129
const canReadApiKey = apiKeyPermissions === true || apiKeyPermissions?.read
132130

133131
const hasPermissionToUnlock: boolean = useMemo(() => {
134-
const collection = permissions?.collections?.[collectionSlug]
135-
136-
if (collection) {
137-
return Boolean('unlock' in collection ? collection.unlock : undefined)
132+
if (docPermissions) {
133+
return Boolean('unlock' in docPermissions ? docPermissions.unlock : undefined)
138134
}
139135

140136
return false
141-
}, [permissions, collectionSlug])
137+
}, [docPermissions])
142138

143139
const handleChangePassword = useCallback(
144140
(changingPassword: boolean) => {

test/access-control/config.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { fileURLToPath } from 'node:url'
22
import path from 'path'
33
const filename = fileURLToPath(import.meta.url)
44
const dirname = path.dirname(filename)
5-
import type { FieldAccess } from 'payload'
5+
import type { FieldAccess, Where } from 'payload'
66

77
import { buildEditorState, type DefaultNodeTypes } from '@payloadcms/richtext-lexical'
88

@@ -112,6 +112,18 @@ export default buildConfigWithDefaults(
112112
setTimeout(resolve, 50, true) // set to 'true' or 'false' here to simulate the response
113113
})
114114
},
115+
unlock: ({ req }) => {
116+
if (req.user && req.user.collection === 'users') {
117+
// admin users can only unlock themselves
118+
return {
119+
id: {
120+
equals: req.user.id,
121+
},
122+
}
123+
}
124+
125+
return false
126+
},
115127
},
116128
auth: true,
117129
fields: [

test/access-control/e2e.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ let payload: PayloadTestSDK<Config>
6161
describe('Access Control', () => {
6262
let page: Page
6363
let url: AdminUrlUtil
64+
let usersUrl: AdminUrlUtil
6465
let restrictedUrl: AdminUrlUtil
6566
let unrestrictedURL: AdminUrlUtil
6667
let readOnlyCollectionUrl: AdminUrlUtil
@@ -81,6 +82,7 @@ describe('Access Control', () => {
8182
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
8283

8384
url = new AdminUrlUtil(serverURL, slug)
85+
usersUrl = new AdminUrlUtil(serverURL, 'users')
8486
restrictedUrl = new AdminUrlUtil(serverURL, fullyRestrictedSlug)
8587
richTextUrl = new AdminUrlUtil(serverURL, 'rich-text')
8688
unrestrictedURL = new AdminUrlUtil(serverURL, unrestrictedSlug)
@@ -614,6 +616,29 @@ describe('Access Control', () => {
614616
await openDocControls(page)
615617
await expect(page.locator('#action-delete')).toBeVisible()
616618
})
619+
620+
test('can only unlock self when admin', async () => {
621+
await page.goto(usersUrl.list)
622+
623+
const adminUserRow = page.locator('.table tr').filter({ hasText: devUser.email })
624+
const nonAdminUserRow = page.locator('.table tr').filter({ hasText: nonAdminEmail })
625+
626+
// Ensure admin user cannot unlock other users
627+
await adminUserRow.locator('.cell-id a').click()
628+
await page.waitForURL(`**/collections/users/**`)
629+
630+
const unlockButton = page.locator('#force-unlock')
631+
await expect(unlockButton).toBeVisible()
632+
await unlockButton.click()
633+
await expect(page.locator('.payload-toast-container')).toContainText('Successfully unlocked')
634+
635+
await page.goto(usersUrl.list)
636+
637+
// Ensure non-admin user cannot see unlock button
638+
await nonAdminUserRow.locator('.cell-id a').click()
639+
await page.waitForURL(`**/collections/users/**`)
640+
await expect(page.locator('#force-unlock')).toBeHidden()
641+
})
617642
})
618643

619644
describe('admin access', () => {

0 commit comments

Comments
 (0)