Skip to content

Commit d6aa6cc

Browse files
authored
fix: add collection property to auth documents, fixing multi tenancy access control issue (#15404)
This PR ensures that all documents returned from auth-enabled collections via local API operations include the `collection` property, which is essential for multi-tenant access control to function correctly. ## Motivation A common pattern in Payload applications is to fetch a user from the database and pass it to subsequent operations: ```typescript const sean = (await payload.find({ collection: 'users', where: { email: { equals: 'sean@payloadcms.com' } } })).docs[0] const result = await payload.find({ collection: 'diaries', overrideAccess: false, user: sean, // pass fetched user }) // result.docs[0]: { title: "sean's diary" } // result.docs[1]: { title: "jarrod's journal" } // Oh no! This is private! ``` This introduces a security vulnerability. The local API expects `user.collection` to be present, but user documents returned by `find`/`findByID` did not include this property. Since the `user` parameter is loosely typed, TypeScript does not catch this error. In the `@payloadcms/plugin-multi-tenant` plugin, `useTenantAccess` checks `req.user.collection` to apply tenant-based access control. This is not an issue within plugin-multi-tenant, because the Payload types mark `req.user.collection` as required - thus it's reasonable to expect this property to be there. When `collection` is `undefined`, the condition `args.req.user.collection === adminUsersSlug` always evaluates to `false`, bypassing tenant access restrictions. ## Changes The `collection` property is now injected into auth collection documents immediately after data is fetched from the database in all collection operations (`find`, `findByID`, `create`, `update`, `delete`, etc.). This makes it available throughout all operation hooks. Type generation has been updated to include `collection` directly in auth collection schemas, producing cleaner types where `User` contains `collection: 'users'` without intersection types. Input types have been updated to make `collection` optional since it's auto-populated. As a fallback, `createLocalReq` now sets `user.collection` to the default admin user collection if missing - this is an additional safeguard. A TODO has been added to throw an error instead in version 4.0. We cannot throw an error now, as this would be a breaking change. [[Link to internal Slack discussion]](https://payloadcms.slack.com/archives/C049BR3QBHC/p1769593583792739)
1 parent ce13e97 commit d6aa6cc

125 files changed

Lines changed: 987 additions & 831 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎packages/next/src/auth/login.ts‎

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,35 @@
11
'use server'
22

3-
importtype{CollectionSlug}from'payload'
3+
importtype{AuthCollectionSlug,LoginResult,MaybePromise,SanitizedConfig}from'payload'
44

55
import{getPayload}from'payload'
66

77
import{setPayloadAuthCookie}from'../utilities/setPayloadAuthCookie.js'
88

9-
typeLoginWithEmail={
10-
collection: CollectionSlug
11-
config: any
9+
typeLoginWithEmail<TSlugextendsAuthCollectionSlug>={
10+
collection: TSlug
11+
config: MaybePromise<SanitizedConfig>
1212
email: string
1313
password: string
1414
username?: never
1515
}
1616

17-
typeLoginWithUsername={
18-
collection: CollectionSlug
19-
config: any
17+
typeLoginWithUsername<TSlugextendsAuthCollectionSlug>={
18+
collection: TSlug
19+
config: MaybePromise<SanitizedConfig>
2020
email?: never
2121
password: string
2222
username: string
2323
}
24-
typeLoginArgs=LoginWithEmail|LoginWithUsername
25-
26-
exportasyncfunctionlogin({ collection, config, email, password, username }: LoginArgs): Promise<{
27-
token?: string
28-
user: any
29-
}>{
24+
typeLoginArgs<TSlugextendsAuthCollectionSlug>=LoginWithEmail<TSlug>|LoginWithUsername<TSlug>
25+
26+
exportasyncfunctionlogin<TSlugextendsAuthCollectionSlug>({
27+
collection,
28+
config,
29+
email,
30+
password,
31+
username,
32+
}: LoginArgs<TSlug>): Promise<LoginResult<TSlug>>{
3033
constpayload=awaitgetPayload({ config,cron: true})
3134

3235
constauthConfig=payload.collections[collection]?.config.auth

‎packages/next/src/auth/logout.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use server'
22

3-
importtype{SanitizedConfig}from'payload'
3+
importtype{MaybePromise,SanitizedConfig}from'payload'
44

55
import{cookiesasgetCookies,headersasnextHeaders}from'next/headers.js'
66
import{createLocalReq,getPayload,logoutOperation}from'payload'
@@ -12,7 +12,7 @@ export async function logout({
1212
config,
1313
}: {
1414
allSessions?: boolean
15-
config: Promise<SanitizedConfig>|SanitizedConfig
15+
config: MaybePromise<SanitizedConfig>
1616
}){
1717
constpayload=awaitgetPayload({ config,cron: true})
1818
constheaders=awaitnextHeaders()

‎packages/next/src/auth/refresh.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
'use server'
22

3-
importtype{CollectionSlug}from'payload'
3+
importtype{CollectionSlug,MaybePromise,SanitizedConfig}from'payload'
44

55
import{headersasnextHeaders}from'next/headers.js'
66
import{createLocalReq,getPayload,refreshOperation}from'payload'
77

88
import{getExistingAuthToken}from'../utilities/getExistingAuthToken.js'
99
import{setPayloadAuthCookie}from'../utilities/setPayloadAuthCookie.js'
1010

11-
exportasyncfunctionrefresh({ config }: {config: any}){
11+
exportasyncfunctionrefresh({ config }: {config: MaybePromise<SanitizedConfig>}){
1212
constpayload=awaitgetPayload({ config,cron: true})
1313
constheaders=awaitnextHeaders()
1414
constresult=awaitpayload.auth({ headers })

‎packages/payload/src/auth/operations/forgotPassword.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
AuthOperationsFromCollectionSlug,
77
Collection,
88
}from'../../collections/config/types.js'
9-
importtype{CollectionSlug}from'../../index.js'
9+
importtype{AuthCollectionSlug}from'../../index.js'
1010
importtype{PayloadRequest,Where}from'../../types/index.js'
1111

1212
import{buildAfterOperation}from'../../collections/operations/utilities/buildAfterOperation.js'
@@ -20,7 +20,7 @@ import { initTransaction } from '../../utilities/initTransaction.js'
2020
import{killTransaction}from'../../utilities/killTransaction.js'
2121
import{getLoginOptions}from'../getLoginOptions.js'
2222

23-
exporttypeArguments<TSlugextendsCollectionSlug>={
23+
exporttypeArguments<TSlugextendsAuthCollectionSlug>={
2424
collection: Collection
2525
data: {
2626
[key: string]: unknown
@@ -33,7 +33,7 @@ export type Arguments<TSlug extends CollectionSlug> = {
3333

3434
exporttypeResult=string
3535

36-
exportconstforgotPasswordOperation=async<TSlugextendsCollectionSlug>(
36+
exportconstforgotPasswordOperation=async<TSlugextendsAuthCollectionSlug>(
3737
incomingArgs: Arguments<TSlug>,
3838
): Promise<null|string>=>{
3939
constloginWithUsername=incomingArgs.collection.config.auth.loginWithUsername

‎packages/payload/src/auth/operations/local/forgotPassword.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
importtype{CollectionSlug,Payload,RequestContext}from'../../../index.js'
1+
importtype{AuthCollectionSlug,Payload,RequestContext}from'../../../index.js'
22
importtype{PayloadRequest}from'../../../types/index.js'
33
importtype{Result}from'../forgotPassword.js'
44

55
import{APIError}from'../../../errors/index.js'
66
import{createLocalReq}from'../../../utilities/createLocalReq.js'
77
import{forgotPasswordOperation}from'../forgotPassword.js'
88

9-
exporttypeOptions<TextendsCollectionSlug>={
10-
collection: T
9+
exporttypeOptions<TSlugextendsAuthCollectionSlug>={
10+
collection: TSlug
1111
context?: RequestContext
1212
data: {
1313
email: string
@@ -18,7 +18,7 @@ export type Options<T extends CollectionSlug> = {
1818
req?: Partial<PayloadRequest>
1919
}
2020

21-
exportasyncfunctionforgotPasswordLocal<TextendsCollectionSlug>(
21+
exportasyncfunctionforgotPasswordLocal<TextendsAuthCollectionSlug>(
2222
payload: Payload,
2323
options: Options<T>,
2424
): Promise<Result>{

‎packages/payload/src/auth/operations/local/login.ts‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
importtype{
2+
AuthCollectionSlug,
23
AuthOperationsFromCollectionSlug,
3-
CollectionSlug,
4-
DataFromCollectionSlug,
54
Payload,
65
RequestContext,
76
}from'../../../index.js'
87
importtype{PayloadRequest}from'../../../types/index.js'
9-
importtype{Result}from'../login.js'
8+
importtype{LoginResult}from'../login.js'
109

1110
import{APIError}from'../../../errors/index.js'
1211
import{createLocalReq}from'../../../utilities/createLocalReq.js'
1312
import{loginOperation}from'../login.js'
1413

15-
exporttypeOptions<TSlugextendsCollectionSlug>={
14+
exporttypeOptions<TSlugextendsAuthCollectionSlug>={
1615
collection: TSlug
1716
context?: RequestContext
1817
data: AuthOperationsFromCollectionSlug<TSlug>['login']
@@ -25,10 +24,10 @@ export type Options<TSlug extends CollectionSlug> = {
2524
trash?: boolean
2625
}
2726

28-
exportasyncfunctionloginLocal<TSlugextendsCollectionSlug>(
27+
exportasyncfunctionloginLocal<TSlugextendsAuthCollectionSlug>(
2928
payload: Payload,
3029
options: Options<TSlug>,
31-
): Promise<{user: DataFromCollectionSlug<TSlug>}&Result>{
30+
): Promise<LoginResult<TSlug>>{
3231
const{
3332
collection: collectionSlug,
3433
data,

‎packages/payload/src/auth/operations/local/resetPassword.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
importtype{CollectionSlug,Payload,RequestContext}from'../../../index.js'
1+
importtype{AuthCollectionSlug,Payload,RequestContext}from'../../../index.js'
22
importtype{PayloadRequest}from'../../../types/index.js'
33
importtype{Result}from'../resetPassword.js'
44

55
import{APIError}from'../../../errors/index.js'
66
import{createLocalReq}from'../../../utilities/createLocalReq.js'
77
import{resetPasswordOperation}from'../resetPassword.js'
88

9-
exporttypeOptions<TextendsCollectionSlug>={
10-
collection: T
9+
exporttypeOptions<TSlugextendsAuthCollectionSlug>={
10+
collection: TSlug
1111
context?: RequestContext
1212
data: {
1313
password: string
@@ -17,7 +17,7 @@ export type Options<T extends CollectionSlug> = {
1717
req?: Partial<PayloadRequest>
1818
}
1919

20-
exportasyncfunctionresetPasswordLocal<TSlugextendsCollectionSlug>(
20+
exportasyncfunctionresetPasswordLocal<TSlugextendsAuthCollectionSlug>(
2121
payload: Payload,
2222
options: Options<TSlug>,
2323
): Promise<Result>{

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
importtype{
2+
AuthCollectionSlug,
23
AuthOperationsFromCollectionSlug,
3-
CollectionSlug,
44
Payload,
55
RequestContext,
66
}from'../../../index.js'
@@ -10,15 +10,15 @@ import { APIError } from '../../../errors/index.js'
1010
import{createLocalReq}from'../../../utilities/createLocalReq.js'
1111
import{unlockOperation}from'../unlock.js'
1212

13-
exporttypeOptions<TSlugextendsCollectionSlug>={
13+
exporttypeOptions<TSlugextendsAuthCollectionSlug>={
1414
collection: TSlug
1515
context?: RequestContext
1616
data: AuthOperationsFromCollectionSlug<TSlug>['unlock']
1717
overrideAccess: boolean
1818
req?: Partial<PayloadRequest>
1919
}
2020

21-
exportasyncfunctionunlockLocal<TSlugextendsCollectionSlug>(
21+
exportasyncfunctionunlockLocal<TSlugextendsAuthCollectionSlug>(
2222
payload: Payload,
2323
options: Options<TSlug>,
2424
): Promise<boolean>{

‎packages/payload/src/auth/operations/local/verifyEmail.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
1-
importtype{CollectionSlug,Payload,RequestContext}from'../../../index.js'
1+
importtype{AuthCollectionSlug,Payload,RequestContext}from'../../../index.js'
22
importtype{PayloadRequest}from'../../../types/index.js'
33

44
import{APIError}from'../../../errors/index.js'
55
import{createLocalReq}from'../../../utilities/createLocalReq.js'
66
import{verifyEmailOperation}from'../verifyEmail.js'
77

8-
exporttypeOptions<TextendsCollectionSlug>={
9-
collection: T
8+
exporttypeOptions<TSlugextendsAuthCollectionSlug>={
9+
collection: TSlug
1010
context?: RequestContext
1111
req?: Partial<PayloadRequest>
1212
token: string
1313
}
1414

15-
exportasyncfunctionverifyEmailLocal<TextendsCollectionSlug>(
15+
exportasyncfunctionverifyEmailLocal<TextendsAuthCollectionSlug>(
1616
payload: Payload,
1717
options: Options<T>,
1818
): Promise<boolean>{

‎packages/payload/src/auth/operations/login.ts‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type {
33
Collection,
44
DataFromCollectionSlug,
55
}from'../../collections/config/types.js'
6-
importtype{CollectionSlug,TypedUser}from'../../index.js'
6+
importtype{AuthCollectionSlug,TypedUser}from'../../index.js'
77
importtype{PayloadRequest,Where}from'../../types/index.js'
88

99
import{buildAfterOperation}from'../../collections/operations/utilities/buildAfterOperation.js'
@@ -28,13 +28,13 @@ import { authenticateLocalStrategy } from '../strategies/local/authenticate.js'
2828
import{incrementLoginAttempts}from'../strategies/local/incrementLoginAttempts.js'
2929
import{resetLoginAttempts}from'../strategies/local/resetLoginAttempts.js'
3030

31-
exporttypeResult={
31+
exporttypeLoginResult<TSlugextendsAuthCollectionSlug>={
3232
exp?: number
3333
token?: string
34-
user?: TypedUser
34+
user?: DataFromCollectionSlug<TSlug>
3535
}
3636

37-
exporttypeArguments<TSlugextendsCollectionSlug>={
37+
exporttypeArguments<TSlugextendsAuthCollectionSlug>={
3838
collection: Collection
3939
data: AuthOperationsFromCollectionSlug<TSlug>['login']
4040
depth?: number
@@ -43,22 +43,22 @@ export type Arguments<TSlug extends CollectionSlug> = {
4343
showHiddenFields?: boolean
4444
}
4545

46-
typeCheckLoginPermissionArgs={
46+
typeCheckLoginPermissionArgs<TSlugextendsAuthCollectionSlug>={
4747
loggingInWithUsername?: boolean
4848
req: PayloadRequest
49-
user: any
49+
user: DataFromCollectionSlug<TSlug>
5050
}
5151

5252
/**
5353
* Throws an error if the user is locked or does not exist.
5454
* This does not check the login attempts, only the lock status. Whoever increments login attempts
5555
* is responsible for locking the user properly, not whoever checks the login permission.
5656
*/
57-
exportconstcheckLoginPermission=({
57+
exportconstcheckLoginPermission=<TSlugextendsAuthCollectionSlug>({
5858
loggingInWithUsername,
5959
req,
6060
user,
61-
}: CheckLoginPermissionArgs)=>{
61+
}: CheckLoginPermissionArgs<TSlug>)=>{
6262
if(!user){
6363
thrownewAuthenticationError(req.t,Boolean(loggingInWithUsername))
6464
}
@@ -68,9 +68,9 @@ export const checkLoginPermission = ({
6868
}
6969
}
7070

71-
exportconstloginOperation=async<TSlugextendsCollectionSlug>(
71+
exportconstloginOperation=async<TSlugextendsAuthCollectionSlug>(
7272
incomingArgs: Arguments<TSlug>,
73-
): Promise<{user: DataFromCollectionSlug<TSlug>}&Result>=>{
73+
): Promise<LoginResult<TSlug>>=>{
7474
letargs=incomingArgs
7575

7676
if(args.collection.config.auth.disableLocalStrategy){
@@ -381,7 +381,7 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
381381
}
382382
}
383383

384-
letresult: {user: DataFromCollectionSlug<TSlug>}&Result={
384+
letresult: LoginResult<TSlug>={
385385
exp,
386386
token,
387387
user,

0 commit comments

Comments
 (0)