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.
697 lines
19 KiB
TypeScript
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',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
})
|
|
})
|
|
})
|
|
})
|