Skip to content

Commit f5a5bd8

Browse files
fix(plugin-multi-tenant): return false instead of query when no tenants (#15679)
Fixes #15638 ## Summary Users with zero tenants generated `{ $in: [] }` queries that Azure CosmosDB cannot handle. This returns access denied instead of the empty query, while still allowing users to access their own user document.
1 parent 677596c commit f5a5bd8

3 files changed

Lines changed: 124 additions & 4 deletions

File tree

packages/plugin-multi-tenant/src/utilities/getTenantAccess.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import type { Where } from 'payload'
2-
31
import type { UserWithTenantsField } from '../types.js'
42

53
import { defaults } from '../defaults.js'
@@ -16,7 +14,11 @@ export function getTenantAccess({
1614
tenantsArrayFieldName = defaults.tenantsArrayFieldName,
1715
tenantsArrayTenantFieldName = defaults.tenantsArrayTenantFieldName,
1816
user,
19-
}: Args): Where {
17+
}: Args): {
18+
[fieldName: string]: {
19+
in: (number | string)[]
20+
}
21+
} {
2022
const userAssignedTenantIDs = getUserTenantIDs(user, {
2123
tenantsArrayFieldName,
2224
tenantsArrayTenantFieldName,

packages/plugin-multi-tenant/src/utilities/withTenantAccess.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,20 @@ export const withTenantAccess =
6565
tenantsArrayTenantFieldName,
6666
user: args.req.user as UserWithTenantsField,
6767
})
68+
69+
// User with no tenants should have no access to tenant-scoped documents
70+
// except for their own user document
71+
if (tenantConstraint[fieldName]?.in.length === 0) {
72+
const result: AccessResult =
73+
collection.slug === args.req.user.collection
74+
? { id: { equals: args.req.user.id } }
75+
: false
76+
77+
return accessResultCallback
78+
? accessResultCallback({ accessKey, accessResult: result, ...args })
79+
: result
80+
}
81+
6882
if (collection.slug === args.req.user.collection) {
6983
constraints.push({
7084
or: [

test/plugin-multi-tenant/int.spec.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
77
import type { NextRESTClient } from '../__helpers/shared/NextRESTClient.js'
88
import type { Relationship } from './payload-types.js'
99

10-
import { devUser } from '../credentials.js'
1110
import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js'
11+
import { devUser } from '../credentials.js'
1212
import { relationshipsSlug, tenantsSlug, usersSlug } from './shared.js'
1313

1414
let payload: Payload
@@ -136,6 +136,110 @@ describe('@payloadcms/plugin-multi-tenant', () => {
136136
})
137137
})
138138

139+
describe('access control for users with no tenant memberships', () => {
140+
it('should return Forbidden error (not 500) for user with no tenants', async () => {
141+
// Create a user with no tenant memberships
142+
const noTenantUser = await payload.create({
143+
collection: usersSlug,
144+
data: {
145+
email: 'no-tenants@test.com',
146+
password: 'test',
147+
tenants: [],
148+
},
149+
})
150+
151+
// Create a tenant and document for testing
152+
const tenant = await payload.create({
153+
collection: tenantsSlug,
154+
data: { name: 'Test Tenant', domain: 'test-tenant.test' },
155+
})
156+
const doc = await payload.create({
157+
collection: relationshipsSlug,
158+
data: { tenant: tenant.id, title: 'Test Doc' },
159+
})
160+
161+
// User with no tenants should get a Forbidden error (clean rejection)
162+
// not a 500 server error (which happens with { $in: [] } on CosmosDB)
163+
await expect(
164+
payload.find({
165+
collection: relationshipsSlug,
166+
overrideAccess: false,
167+
user: noTenantUser,
168+
where: { id: { equals: doc.id } },
169+
}),
170+
).rejects.toThrow('You are not allowed to perform this action.')
171+
172+
// Cleanup
173+
await payload.delete({ id: doc.id, collection: relationshipsSlug })
174+
await payload.delete({ id: tenant.id, collection: tenantsSlug })
175+
await payload.delete({ id: noTenantUser.id, collection: usersSlug })
176+
})
177+
178+
it('should allow user with no tenants to access their own user document', async () => {
179+
// Create a user with no tenant memberships
180+
const noTenantUser = await payload.create({
181+
collection: usersSlug,
182+
data: {
183+
email: 'no-tenants-self@test.com',
184+
password: 'test',
185+
tenants: [],
186+
},
187+
})
188+
189+
// User should be able to find themselves
190+
const result = await payload.find({
191+
collection: usersSlug,
192+
overrideAccess: false,
193+
user: noTenantUser,
194+
where: { id: { equals: noTenantUser.id } },
195+
})
196+
197+
expect(result.docs).toHaveLength(1)
198+
expect(result.docs[0].id).toBe(noTenantUser.id)
199+
200+
// Cleanup
201+
await payload.delete({ id: noTenantUser.id, collection: usersSlug })
202+
})
203+
204+
it('should allow admin with empty tenants array to access all documents', async () => {
205+
// Create an admin user with empty tenants array
206+
const adminUser = await payload.create({
207+
collection: usersSlug,
208+
data: {
209+
email: 'admin-empty-tenants@test.com',
210+
password: 'test',
211+
tenants: [],
212+
roles: ['admin'],
213+
},
214+
})
215+
216+
// Create a tenant and document
217+
const tenant = await payload.create({
218+
collection: tenantsSlug,
219+
data: { name: 'Admin Test Tenant', domain: 'admin-test.test' },
220+
})
221+
const doc = await payload.create({
222+
collection: relationshipsSlug,
223+
data: { tenant: tenant.id, title: 'Admin Test Doc' },
224+
})
225+
226+
// Admin should have access (userHasAccessToAllTenants returns true for super-admin)
227+
const result = await payload.find({
228+
collection: relationshipsSlug,
229+
overrideAccess: false,
230+
user: adminUser,
231+
where: { id: { equals: doc.id } },
232+
})
233+
234+
expect(result.docs).toHaveLength(1)
235+
236+
// Cleanup
237+
await payload.delete({ id: doc.id, collection: relationshipsSlug })
238+
await payload.delete({ id: tenant.id, collection: tenantsSlug })
239+
await payload.delete({ id: adminUser.id, collection: usersSlug })
240+
})
241+
})
242+
139243
describe('access control with user object passed directly', () => {
140244
it('should enforce tenant access when user object is fetched from database', async () => {
141245
// Create two tenants

0 commit comments

Comments
 (0)