Files
payloadcms/test/query-presets/int.spec.ts
Jacob Fletcher 9779cf7f7d feat: prevent query preset lockout (#12322)
Prevents an accidental lockout of query preset documents. An "accidental
lockout" occurs when the user sets access control on a preset and
excludes themselves. This can happen in a variety of scenarios,
including:

 - You select `specificUsers` without specifying yourself
- You select `specificRoles` without specifying a role that you are a
part of
 - Etc.

#### How it works

To make this happen, we use a custom validation function that executes
access against the user's proposed changes. If those changes happen to
remove access for them, we throw a validation error and prevent that
change from ever taking place. This means that only a user with proper
access can remove another user from the preset. You cannot remove
yourself.

To do this, we create a temporary record in the database that we can
query against. We use transactions to ensure that the temporary record
is not persisted once our work is completed. Since not all Payload
projects have transactions enabled, we flag these temporary records with
the `isTemp` field.

Once created, we query the temp document to determine its permissions.
If any of the operations throw an error, this means the user can no
longer act on them, and we throw a validation error.

#### Alternative Approach
 
A previous approach that was explored was to add an `owner` field to the
presets collection. This way, the "owner" of the preset would be able to
completely bypass all access control, effectively eliminating the
possibility of a lockout event.

But this doesn't work for other users who may have update access. E.g.
they could still accidentally remove themselves from the read or update
operation, preventing them from accessing that preset after submitting
the form. We need a solution that works for all users, not just the
owner.
2025-05-14 19:25:32 +00:00

697 lines
19 KiB
TypeScript

import type { Payload, User } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import { devUser, regularUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
const queryPresetsCollectionSlug = 'payload-query-presets'
let payload: Payload
let adminUser: User
let editorUser: User
let publicUser: User
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('Query Presets', () => {
beforeAll(async () => {
// @ts-expect-error: initPayloadInt does not have a proper type definition
;({ payload } = await initPayloadInt(dirname))
adminUser = await payload
.login({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
?.then((result) => result.user)
editorUser = await payload
.login({
collection: 'users',
data: {
email: regularUser.email,
password: regularUser.password,
},
})
?.then((result) => result.user)
publicUser = await payload
.login({
collection: 'users',
data: {
email: 'public@email.com',
password: regularUser.password,
},
})
?.then((result) => result.user)
})
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy()
}
})
describe('default access control', () => {
it('should only allow logged in users to perform actions', async () => {
// create
try {
const result = await payload.create({
collection: queryPresetsCollectionSlug,
user: undefined,
overrideAccess: false,
data: {
title: 'Only Logged In Users',
relatedCollection: 'pages',
},
})
expect(result).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
}
const { id } = await payload.create({
collection: queryPresetsCollectionSlug,
data: {
title: 'Only Logged In Users',
relatedCollection: 'pages',
},
})
// read
try {
const result = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: undefined,
overrideAccess: false,
id,
})
expect(result).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
}
// update
try {
const result = await payload.update({
collection: queryPresetsCollectionSlug,
id,
user: undefined,
overrideAccess: false,
data: {
title: 'Only Logged In Users (Updated)',
},
})
expect(result).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
// make sure the update didn't go through
const preset = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
id,
})
expect(preset.title).toBe('Only Logged In Users')
}
// delete
try {
const result = await payload.delete({
collection: queryPresetsCollectionSlug,
id: 'some-id',
user: undefined,
overrideAccess: false,
})
expect(result).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
// make sure the delete didn't go through
const preset = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
id,
})
expect(preset.title).toBe('Only Logged In Users')
}
})
it('should respect access when set to "specificUsers"', async () => {
const presetForSpecificUsers = await payload.create({
collection: queryPresetsCollectionSlug,
user: adminUser,
overrideAccess: false,
data: {
title: 'Specific Users',
where: {
text: {
equals: 'example page',
},
},
access: {
read: {
constraint: 'specificUsers',
users: [adminUser.id],
},
update: {
constraint: 'specificUsers',
users: [adminUser.id],
},
},
relatedCollection: 'pages',
},
})
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: adminUser,
overrideAccess: false,
id: presetForSpecificUsers.id,
})
expect(foundPresetWithUser1.id).toBe(presetForSpecificUsers.id)
try {
const foundPresetWithEditorUser = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: editorUser,
overrideAccess: false,
id: presetForSpecificUsers.id,
})
expect(foundPresetWithEditorUser).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('Not Found')
}
const presetUpdatedByAdminUser = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForSpecificUsers.id,
user: adminUser,
overrideAccess: false,
data: {
title: 'Specific Users (Updated)',
},
})
expect(presetUpdatedByAdminUser.title).toBe('Specific Users (Updated)')
try {
const presetUpdatedByEditorUser = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForSpecificUsers.id,
user: editorUser,
overrideAccess: false,
data: {
title: 'Specific Users (Updated)',
},
})
expect(presetUpdatedByEditorUser).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
}
})
it('should respect access when set to "onlyMe"', async () => {
const presetForOnlyMe = await payload.create({
collection: queryPresetsCollectionSlug,
overrideAccess: false,
user: adminUser,
data: {
title: 'Only Me',
where: {
text: {
equals: 'example page',
},
},
access: {
read: {
constraint: 'onlyMe',
},
update: {
constraint: 'onlyMe',
},
},
relatedCollection: 'pages',
},
})
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: adminUser,
overrideAccess: false,
id: presetForOnlyMe.id,
})
expect(foundPresetWithUser1.id).toBe(presetForOnlyMe.id)
try {
const foundPresetWithEditorUser = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: editorUser,
overrideAccess: false,
id: presetForOnlyMe.id,
})
expect(foundPresetWithEditorUser).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('Not Found')
}
const presetUpdatedByUser1 = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForOnlyMe.id,
user: adminUser,
overrideAccess: false,
data: {
title: 'Only Me (Updated)',
},
})
expect(presetUpdatedByUser1.title).toBe('Only Me (Updated)')
try {
const presetUpdatedByEditorUser = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForOnlyMe.id,
user: editorUser,
overrideAccess: false,
data: {
title: 'Only Me (Updated)',
},
})
expect(presetUpdatedByEditorUser).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
}
})
it('should respect access when set to "everyone"', async () => {
const presetForEveryone = await payload.create({
collection: queryPresetsCollectionSlug,
overrideAccess: false,
user: adminUser,
data: {
title: 'Everyone',
where: {
text: {
equals: 'example page',
},
},
access: {
read: {
constraint: 'everyone',
},
update: {
constraint: 'everyone',
},
delete: {
constraint: 'everyone',
},
},
relatedCollection: 'pages',
},
})
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: adminUser,
overrideAccess: false,
id: presetForEveryone.id,
})
expect(foundPresetWithUser1.id).toBe(presetForEveryone.id)
const foundPresetWithEditorUser = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: editorUser,
overrideAccess: false,
id: presetForEveryone.id,
})
expect(foundPresetWithEditorUser.id).toBe(presetForEveryone.id)
const presetUpdatedByUser1 = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForEveryone.id,
user: adminUser,
overrideAccess: false,
data: {
title: 'Everyone (Update 1)',
},
})
expect(presetUpdatedByUser1.title).toBe('Everyone (Update 1)')
const presetUpdatedByEditorUser = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForEveryone.id,
user: editorUser,
overrideAccess: false,
data: {
title: 'Everyone (Update 2)',
},
})
expect(presetUpdatedByEditorUser.title).toBe('Everyone (Update 2)')
})
it('should prevent accidental lockout', async () => {
// attempt to create a preset without access to read or update
try {
const presetWithoutAccess = await payload.create({
collection: queryPresetsCollectionSlug,
user: adminUser,
overrideAccess: false,
data: {
title: 'Prevent Lockout',
relatedCollection: 'pages',
access: {
read: {
constraint: 'specificUsers',
users: [],
},
update: {
constraint: 'specificUsers',
users: [],
},
delete: {
constraint: 'specificUsers',
users: [],
},
},
},
})
expect(presetWithoutAccess).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('Cannot remove yourself from this preset.')
}
const presetWithUser1 = await payload.create({
collection: queryPresetsCollectionSlug,
user: adminUser,
overrideAccess: false,
data: {
title: 'Prevent Lockout',
relatedCollection: 'pages',
access: {
read: {
constraint: 'specificUsers',
users: [adminUser.id],
},
update: {
constraint: 'specificUsers',
users: [adminUser.id],
},
delete: {
constraint: 'specificUsers',
users: [adminUser.id],
},
},
},
})
// attempt to update the preset to lock the user out of access
try {
const presetUpdatedByUser1 = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetWithUser1.id,
user: adminUser,
overrideAccess: false,
data: {
title: 'Prevent Lockout (Updated)',
access: {
read: {
constraint: 'specificUsers',
users: [],
},
update: {
constraint: 'specificUsers',
users: [],
},
delete: {
constraint: 'specificUsers',
users: [],
},
},
},
})
expect(presetUpdatedByUser1).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('Cannot remove yourself from this preset.')
}
})
})
describe('user-defined access control', () => {
it('should respect top-level access control overrides', async () => {
const preset = await payload.create({
collection: queryPresetsCollectionSlug,
user: adminUser,
overrideAccess: false,
data: {
title: 'Top-Level Access Control Override',
relatedCollection: 'pages',
access: {
read: {
constraint: 'everyone',
},
update: {
constraint: 'everyone',
},
delete: {
constraint: 'everyone',
},
},
},
})
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: adminUser,
overrideAccess: false,
id: preset.id,
})
expect(foundPresetWithUser1.id).toBe(preset.id)
try {
const foundPresetWithPublicUser = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: publicUser,
overrideAccess: false,
id: preset.id,
})
expect(foundPresetWithPublicUser).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
}
})
it('should respect access when set to "specificRoles"', async () => {
const presetForSpecificRoles = await payload.create({
collection: queryPresetsCollectionSlug,
user: adminUser,
overrideAccess: false,
data: {
title: 'Specific Roles',
where: {
text: {
equals: 'example page',
},
},
access: {
read: {
constraint: 'specificRoles',
roles: ['admin'],
},
update: {
constraint: 'specificRoles',
roles: ['admin'],
},
},
relatedCollection: 'pages',
},
})
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: adminUser,
overrideAccess: false,
id: presetForSpecificRoles.id,
})
expect(foundPresetWithUser1.id).toBe(presetForSpecificRoles.id)
try {
const foundPresetWithEditorUser = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: editorUser,
overrideAccess: false,
id: presetForSpecificRoles.id,
})
expect(foundPresetWithEditorUser).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('Not Found')
}
const presetUpdatedByUser1 = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForSpecificRoles.id,
user: adminUser,
overrideAccess: false,
data: {
title: 'Specific Roles (Updated)',
},
})
expect(presetUpdatedByUser1.title).toBe('Specific Roles (Updated)')
try {
const presetUpdatedByEditorUser = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForSpecificRoles.id,
user: editorUser,
overrideAccess: false,
data: {
title: 'Specific Roles (Updated)',
},
})
expect(presetUpdatedByEditorUser).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
}
})
it('should respect boolean access control results', async () => {
// create a preset with the read constraint set to "noone"
const presetForNoone = await payload.create({
collection: queryPresetsCollectionSlug,
user: adminUser,
data: {
relatedCollection: 'pages',
title: 'Noone',
where: {
text: {
equals: 'example page',
},
},
access: {
read: {
constraint: 'noone',
},
},
},
})
try {
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: adminUser,
overrideAccess: false,
id: presetForNoone.id,
})
expect(foundPresetWithUser1).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('Not Found')
}
})
})
it.skip('should disable query presets when "enabledQueryPresets" is not true on the collection', async () => {
try {
const result = await payload.create({
collection: 'payload-query-presets',
user: adminUser,
overrideAccess: false,
data: {
title: 'Disabled Query Presets',
relatedCollection: 'pages',
},
})
// TODO: this test always passes because this expect throws an error which is caught and passes the 'catch' block
expect(result).toBeFalsy()
} catch (error) {
expect(error).toBeDefined()
}
})
describe('Where object formatting', () => {
it('transforms "where" query objects into the "and" / "or" format', async () => {
const result = await payload.create({
collection: queryPresetsCollectionSlug,
user: adminUser,
overrideAccess: false,
data: {
title: 'Where Object Formatting',
where: {
text: {
equals: 'example page',
},
},
access: {
read: {
constraint: 'everyone',
},
update: {
constraint: 'everyone',
},
delete: {
constraint: 'everyone',
},
},
relatedCollection: 'pages',
},
})
expect(result.where).toMatchObject({
or: [
{
and: [
{
text: {
equals: 'example page',
},
},
],
},
],
})
})
})
})