feat: filter query preset constraints (#12485)

You can now specify exactly who can change the constraints within a
query preset.

For example, you want to ensure that only "admins" are allowed to set a
preset to "everyone".

To do this, you can use the new `queryPresets.filterConstraints`
property. When a user lacks the permission to change a constraint, the
option will either be hidden from them or disabled if it is already set.

```ts
import { buildConfig } from 'payload'

const config = buildConfig({
  // ...
  queryPresets: {
    // ...
    filterConstraints: ({ req, options }) =>
      !req.user?.roles?.includes('admin')
        ? options.filter(
            (option) =>
              (typeof option === 'string' ? option : option.value) !==
              'everyone',
          )
        : options,
  },
})
```

The `filterConstraints` functions takes the same arguments as
`reduceOptions` property on select fields introduced in #12487.
This commit is contained in:
Jacob Fletcher
2025-05-27 16:55:37 -04:00
committed by GitHub
parent 032375b016
commit 0204f0dcbc
9 changed files with 195 additions and 42 deletions

View File

@@ -70,7 +70,7 @@ _\* An asterisk denotes that a property is required._
### filterOptions
Used to dynamically filter which options are available based on the user, data, etc.
Used to dynamically filter which options are available based on the current user, document data, or other criteria.
Some examples of this might include:

View File

@@ -46,11 +46,12 @@ const config = buildConfig({
The following options are available for Query Presets:
| Option | Description |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `access` | Used to define custom collection-level access control that applies to all presets. [More details](#access-control). |
| `constraints` | Used to define custom document-level access control that apply to individual presets. [More details](#document-access-control). |
| `labels` | Custom labels to use for the Query Presets collection. |
| Option | Description |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `access` | Used to define custom collection-level access control that applies to all presets. [More details](#access-control). |
| `filterConstraints` | Used to define which constraints are available to users when managing presets. [More details](#constraint-access-control). |
| `constraints` | Used to define custom document-level access control that apply to individual presets. [More details](#document-access-control). |
| `labels` | Custom labels to use for the Query Presets collection. |
## Access Control
@@ -59,7 +60,7 @@ Query Presets are subject to the same [Access Control](../access-control/overvie
Access Control for Query Presets can be customized in two ways:
1. [Collection Access Control](#collection-access-control): Applies to all presets. These rules are not controllable by the user and are statically defined in the config.
2. [Document Access Control](#document-access-control): Applies to each individual preset. These rules are controllable by the user and are saved to the document.
2. [Document Access Control](#document-access-control): Applies to each individual preset. These rules are controllable by the user and are dynamically defined on each record in the database.
### Collection Access Control
@@ -97,7 +98,7 @@ This example restricts all Query Presets to users with the role of `admin`.
### Document Access Control
You can also define access control rules that apply to each specific preset. Users have the ability to define and modify these rules on the fly as they manage presets. These are saved dynamically in the database on each document.
You can also define access control rules that apply to each specific preset. Users have the ability to define and modify these rules on the fly as they manage presets. These are saved dynamically in the database on each record.
When a user manages a preset, document-level access control options will be available to them in the Admin Panel for each operation.
@@ -150,8 +151,8 @@ const config = buildConfig({
}),
},
],
// highlight-end
},
// highlight-end
},
})
```
@@ -171,3 +172,39 @@ The following options are available for each constraint:
| `value` | The value to store in the database when this constraint is selected. |
| `fields` | An array of fields to render when this constraint is selected. |
| `access` | A function that determines the access control rules for this constraint. |
### Constraint Access Control
Used to dynamically filter which constraints are available based on the current user, document data, or other criteria.
Some examples of this might include:
- Ensuring that only "admins" are allowed to make a preset available to "everyone"
- Preventing the "onlyMe" option from being selected based on a hypothetical "disablePrivatePresets" checkbox
When a user lacks the permission to set a constraint, the option will either be hidden from them, or disabled if it is already saved to that preset.
To do this, you can use the `filterConstraints` property in your [Payload Config](../configuration/overview):
```ts
import { buildConfig } from 'payload'
const config = buildConfig({
// ...
queryPresets: {
// ...
// highlight-start
filterConstraints: ({ req, options }) =>
!req.user?.roles?.includes('admin')
? options.filter(
(option) =>
(typeof option === 'string' ? option : option.value) !==
'everyone',
)
: options,
// highlight-end
},
})
```
The `filterConstraints` function receives the same arguments as [`filterOptions`](../fields/select#filterOptions) in the [Select field](../fields/select).

View File

@@ -48,6 +48,7 @@ import type {
JobsConfig,
Payload,
RequestContext,
SelectField,
TypedUser,
} from '../index.js'
import type { QueryPreset, QueryPresetConstraints } from '../query-presets/types.js'
@@ -1131,6 +1132,7 @@ export type Config = {
read?: QueryPresetConstraints
update?: QueryPresetConstraints
}
filterConstraints?: SelectField['filterOptions']
labels?: CollectionConfig['labels']
}
/** Control the routing structure that Payload binds itself to. */

View File

@@ -1,12 +1,28 @@
import { getTranslation } from '@payloadcms/translations'
import type { Config } from '../config/types.js'
import type { Field } from '../fields/config/types.js'
import type { Field, Option } from '../fields/config/types.js'
import type { QueryPresetConstraint } from './types.js'
import { fieldAffectsData } from '../fields/config/types.js'
import { toWords } from '../utilities/formatLabels.js'
import { preventLockout } from './preventLockout.js'
import { operations, type QueryPresetConstraint } from './types.js'
import { operations } from './types.js'
const defaultConstraintOptions: Option[] = [
{
label: 'Everyone',
value: 'everyone',
},
{
label: 'Only Me',
value: 'onlyMe',
},
{
label: 'Specific Users',
value: 'specificUsers',
},
]
export const getConstraints = (config: Config): Field => ({
name: 'access',
@@ -17,11 +33,11 @@ export const getConstraints = (config: Config): Field => ({
},
condition: (data) => Boolean(data?.isShared),
},
fields: operations.map((operation) => ({
fields: operations.map((constraintOperation) => ({
type: 'collapsible',
fields: [
{
name: operation,
name: constraintOperation,
type: 'group',
admin: {
hideGutter: true,
@@ -31,22 +47,15 @@ export const getConstraints = (config: Config): Field => ({
name: 'constraint',
type: 'select',
defaultValue: 'onlyMe',
filterOptions: (args) =>
typeof config?.queryPresets?.filterConstraints === 'function'
? config.queryPresets.filterConstraints(args)
: args.options,
label: ({ i18n }) =>
`Specify who can ${operation} this ${getTranslation(config.queryPresets?.labels?.singular || 'Preset', i18n)}`,
`Specify who can ${constraintOperation} this ${getTranslation(config.queryPresets?.labels?.singular || 'Preset', i18n)}`,
options: [
{
label: 'Everyone',
value: 'everyone',
},
{
label: 'Only Me',
value: 'onlyMe',
},
{
label: 'Specific Users',
value: 'specificUsers',
},
...(config?.queryPresets?.constraints?.[operation]?.map(
...defaultConstraintOptions,
...(config?.queryPresets?.constraints?.[constraintOperation]?.map(
(option: QueryPresetConstraint) => ({
label: option.label,
value: option.value,
@@ -59,27 +68,28 @@ export const getConstraints = (config: Config): Field => ({
type: 'relationship',
admin: {
condition: (data) =>
Boolean(data?.access?.[operation]?.constraint === 'specificUsers'),
Boolean(data?.access?.[constraintOperation]?.constraint === 'specificUsers'),
},
hasMany: true,
hooks: {
beforeChange: [
({ data, req }) => {
if (data?.access?.[operation]?.constraint === 'onlyMe' && req.user) {
if (data?.access?.[constraintOperation]?.constraint === 'onlyMe' && req.user) {
return [req.user.id]
}
if (data?.access?.[operation]?.constraint === 'specificUsers' && req.user) {
return [...(data?.access?.[operation]?.users || []), req.user.id]
if (
data?.access?.[constraintOperation]?.constraint === 'specificUsers' &&
req.user
) {
return [...(data?.access?.[constraintOperation]?.users || []), req.user.id]
}
return data?.access?.[operation]?.users
},
],
},
relationTo: config.admin?.user ?? 'users', // TODO: remove this fallback when the args are properly typed as `SanitizedConfig`
},
...(config?.queryPresets?.constraints?.[operation]?.reduce(
...(config?.queryPresets?.constraints?.[constraintOperation]?.reduce(
(acc: Field[], option: QueryPresetConstraint) => {
option.fields?.forEach((field, index) => {
acc.push({ ...field })
@@ -88,7 +98,7 @@ export const getConstraints = (config: Config): Field => ({
acc[index].admin = {
...(acc[index]?.admin || {}),
condition: (data) =>
Boolean(data?.access?.[operation]?.constraint === option.value),
Boolean(data?.access?.[constraintOperation]?.constraint === option.value),
}
}
})
@@ -101,7 +111,7 @@ export const getConstraints = (config: Config): Field => ({
label: false,
},
],
label: () => toWords(operation),
label: () => toWords(constraintOperation),
})),
label: 'Sharing settings',
validate: preventLockout,

View File

@@ -6,12 +6,16 @@ import type { Where } from '../types/index.js'
// Note: order matters here as it will change the rendered order in the UI
export const operations = ['read', 'update', 'delete'] as const
type Operation = (typeof operations)[number]
export type ConstraintOperation = (typeof operations)[number]
export type DefaultConstraint = 'everyone' | 'onlyMe' | 'specificUsers'
export type Constraint = DefaultConstraint | string // TODO: type `string` as the custom constraints provided by the config
export type QueryPreset = {
access: {
[operation in Operation]: {
constraint: 'everyone' | 'onlyMe' | 'specificUsers'
[operation in ConstraintOperation]: {
constraint: DefaultConstraint
users?: string[]
}
}

View File

@@ -11,6 +11,7 @@ import { seed } from './seed.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
// eslint-disable-next-line no-restricted-exports
export default buildConfigWithDefaults({
admin: {
importMap: {
@@ -26,6 +27,12 @@ export default buildConfigWithDefaults({
read: ({ req: { user } }) => Boolean(user?.roles?.length && !user?.roles?.includes('user')),
update: ({ req: { user } }) => Boolean(user?.roles?.length && !user?.roles?.includes('user')),
},
filterConstraints: ({ req, options }) =>
!req.user?.roles?.includes('admin')
? options.filter(
(option) => (typeof option === 'string' ? option : option.value) !== 'onlyAdmins',
)
: options,
constraints: {
read: [
{
@@ -43,6 +50,11 @@ export default buildConfigWithDefaults({
value: 'noone',
access: () => false,
},
{
label: 'Only Admins',
value: 'onlyAdmins',
access: ({ req: { user } }) => Boolean(user?.roles?.includes('admin')),
},
],
update: [
{
@@ -55,6 +67,11 @@ export default buildConfigWithDefaults({
},
}),
},
{
label: 'Only Admins',
value: 'onlyAdmins',
access: ({ req: { user } }) => Boolean(user?.roles?.includes('admin')),
},
],
},
},

View File

@@ -545,6 +545,89 @@ describe('Query Presets', () => {
}
})
it('should only allow admins to select the "onlyAdmins" preset (via `filterOptions`)', async () => {
try {
const presetForAdminsCreatedByEditor = await payload.create({
collection: queryPresetsCollectionSlug,
user: editorUser,
overrideAccess: false,
data: {
title: 'Admins (Created by Editor)',
where: {
text: {
equals: 'example page',
},
},
access: {
read: {
constraint: 'onlyAdmins',
},
update: {
constraint: 'onlyAdmins',
},
},
relatedCollection: 'pages',
},
})
expect(presetForAdminsCreatedByEditor).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe(
'The following fields are invalid: Sharing settings > Read > Specify who can read this Preset, Sharing settings > Update > Specify who can update this Preset',
)
}
const presetForAdminsCreatedByAdmin = await payload.create({
collection: queryPresetsCollectionSlug,
user: adminUser,
overrideAccess: false,
data: {
title: 'Admins (Created by Admin)',
where: {
text: {
equals: 'example page',
},
},
access: {
read: {
constraint: 'onlyAdmins',
},
update: {
constraint: 'onlyAdmins',
},
},
relatedCollection: 'pages',
},
})
expect(presetForAdminsCreatedByAdmin).toBeDefined()
// attempt to update the preset using an editor user
try {
const presetUpdatedByEditorUser = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForAdminsCreatedByAdmin.id,
user: editorUser,
overrideAccess: false,
data: {
title: 'From `onlyAdmins` to `onlyMe` (Updated by Editor)',
access: {
read: {
constraint: 'onlyMe',
},
update: {
constraint: 'onlyMe',
},
},
},
})
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 "specificRoles"', async () => {
const presetForSpecificRoles = await payload.create({
collection: queryPresetsCollectionSlug,

View File

@@ -227,12 +227,12 @@ export interface PayloadQueryPreset {
isShared?: boolean | null;
access?: {
read?: {
constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles' | 'noone') | null;
constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles' | 'noone' | 'onlyAdmins') | null;
users?: (string | User)[] | null;
roles?: ('admin' | 'editor' | 'user')[] | null;
};
update?: {
constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles') | null;
constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles' | 'onlyAdmins') | null;
users?: (string | User)[] | null;
roles?: ('admin' | 'editor' | 'user')[] | null;
};

View File

@@ -31,7 +31,7 @@
}
],
"paths": {
"@payload-config": ["./test/_community/config.ts"],
"@payload-config": ["./test/query-presets/config.ts"],
"@payloadcms/admin-bar": ["./packages/admin-bar/src"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],