Skip to content

Commit 0a37edb

Browse files
authored
feat: add access control for jobs queue and cancel operations (#14404)
Adds access control for jobs `queue` and `cancel` operations, similar to what we have for `run` operations. **Implementation:** Both operations now support `overrideAccess` parameter (defaults to `true`) and respect `jobsConfig.access.queue` and `jobsConfig.access.cancel` functions. When `overrideAccess: false` is passed without an authenticated request, operations throw `Forbidden` error. **Configuration:** Access control functions can be defined in the Jobs Config under `jobs.access.queue`, `jobs.access.run`, and `jobs.access.cancel`. Each function receives `{ req }` and returns a boolean. If no custom access control is defined, the default allows any authenticated user to perform the operation. **Example:** ```ts // Configure access control jobs: { access: { queue: ({ req }) => req.user?.roles?.includes('admin'), cancel: ({ req }) => req.user?.roles?.includes('admin'), } } // Use in Local API await payload.jobs.cancel({ where: { workflowSlug: { equals: 'sync' } }, overrideAccess: false, req, }) ```
1 parent 38f2e1f commit 0a37edb

File tree

6 files changed

+389
-23
lines changed

6 files changed

+389
-23
lines changed

docs/jobs-queue/jobs.mdx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,51 @@ const createdJob = await payload.jobs.queue({
4747
})
4848
```
4949

50+
#### Access Control
51+
52+
By default, Payload's job operations bypass access control when used from the Local API. You can enable access control by passing `overrideAccess: false` to any job operation.
53+
54+
To define custom access control for jobs, add an `access` property to your Jobs Config:
55+
56+
```ts
57+
import type { SanitizedConfig } from 'payload'
58+
59+
const config: SanitizedConfig = {
60+
// ...
61+
jobs: {
62+
access: {
63+
// Control who can queue new jobs
64+
queue: ({ req }) => {
65+
return req.user?.roles?.includes('admin')
66+
},
67+
// Control who can run jobs
68+
run: ({ req }) => {
69+
return req.user?.roles?.includes('admin')
70+
},
71+
// Control who can cancel jobs
72+
cancel: ({ req }) => {
73+
return req.user?.roles?.includes('admin')
74+
},
75+
},
76+
},
77+
}
78+
```
79+
80+
Each access control function receives the current `req` object and should return a boolean. If no access control is defined, the default behavior allows any authenticated user to perform the operation.
81+
82+
To use access control in the Local API:
83+
84+
```ts
85+
const req = await createLocalReq({ user }, payload)
86+
87+
await payload.jobs.queue({
88+
workflow: 'createPost',
89+
input: { title: 'My Post' },
90+
overrideAccess: false, // Enable access control
91+
req, // Pass the request with user context
92+
})
93+
```
94+
5095
#### Cancelling Jobs
5196

5297
Payload allows you to cancel jobs that are either queued or currently running. When cancelling a running job, the current task will finish executing, but no subsequent tasks will run. This happens because the job checks its cancellation status between tasks.

packages/payload/src/config/defaults.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export const defaults: Omit<Config, 'db' | 'editor' | 'secret'> = {
5858
i18n: {},
5959
jobs: {
6060
access: {
61+
cancel: defaultAccess,
62+
queue: defaultAccess,
6163
run: defaultAccess,
6264
},
6365
deleteJobOnComplete: true,
@@ -138,6 +140,8 @@ export const addDefaultsToConfig = (config: Config): Config => {
138140
depth: 0,
139141
...(config.jobs || {}),
140142
access: {
143+
cancel: defaultAccess,
144+
queue: defaultAccess,
141145
run: defaultAccess,
142146
...(config.jobs?.access || {}),
143147
},

packages/payload/src/queues/config/types/index.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ export type RunJobAccessArgs = {
7171

7272
export type RunJobAccess = (args: RunJobAccessArgs) => boolean | Promise<boolean>
7373

74+
export type QueueJobAccessArgs = {
75+
req: PayloadRequest
76+
}
77+
78+
export type CancelJobAccessArgs = {
79+
req: PayloadRequest
80+
}
81+
export type CancelJobAccess = (args: CancelJobAccessArgs) => boolean | Promise<boolean>
82+
export type QueueJobAccess = (args: QueueJobAccessArgs) => boolean | Promise<boolean>
83+
7484
export type SanitizedJobsConfig = {
7585
/**
7686
* If set to `true`, the job system is enabled and a payload-jobs collection exists.
@@ -94,7 +104,15 @@ export type JobsConfig = {
94104
*/
95105
access?: {
96106
/**
97-
* By default, all logged-in users can trigger jobs.
107+
* By default, all logged-in users can cancel jobs.
108+
*/
109+
cancel?: CancelJobAccess
110+
/**
111+
* By default, all logged-in users can queue jobs.
112+
*/
113+
queue?: QueueJobAccess
114+
/**
115+
* By default, all logged-in users can run jobs.
98116
*/
99117
run?: RunJobAccess
100118
}

packages/payload/src/queues/localAPI.ts

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { BaseJob, RunningJobFromTask } from './config/types/workflowTypes.j
22

33
import {
44
createLocalReq,
5+
Forbidden,
56
type Job,
67
type Payload,
78
type PayloadRequest,
@@ -56,16 +57,33 @@ export const getJobsLocalAPI = (payload: Payload) => ({
5657
| {
5758
input: TypedJobs['tasks'][TTaskOrWorkflowSlug]['input']
5859
meta?: BaseJob['meta']
60+
/**
61+
* If set to false, access control as defined in jobsConfig.access.queue will be run.
62+
* By default, this is true and no access control will be run.
63+
* If you set this to false and do not have jobsConfig.access.queue defined, the default access control will be
64+
* run (which is a function that returns `true` if the user is logged in).
65+
*
66+
* @default true
67+
*/
68+
overrideAccess?: boolean
5969
queue?: string
6070
req?: PayloadRequest
61-
// TTaskOrWorkflowlug with keyof TypedJobs['workflows'] removed:
6271
task: TTaskOrWorkflowSlug extends keyof TypedJobs['tasks'] ? TTaskOrWorkflowSlug : never
6372
waitUntil?: Date
6473
workflow?: never
6574
}
6675
| {
6776
input: TypedJobs['workflows'][TTaskOrWorkflowSlug]['input']
6877
meta?: BaseJob['meta']
78+
/**
79+
* If set to false, access control as defined in jobsConfig.access.queue will be run.
80+
* By default, this is true and no access control will be run.
81+
* If you set this to false and do not have jobsConfig.access.queue defined, the default access control will be
82+
* run (which is a function that returns `true` if the user is logged in).
83+
*
84+
* @default true
85+
*/
86+
overrideAccess?: boolean
6987
queue?: string
7088
req?: PayloadRequest
7189
task?: never
@@ -79,6 +97,20 @@ export const getJobsLocalAPI = (payload: Payload) => ({
7997
? Job<TTaskOrWorkflowSlug>
8098
: RunningJobFromTask<TTaskOrWorkflowSlug>
8199
> => {
100+
const overrideAccess = args?.overrideAccess !== false
101+
const req: PayloadRequest = args.req ?? (await createLocalReq({}, payload))
102+
103+
if (!overrideAccess) {
104+
/**
105+
* By default, jobsConfig.access.queue will be `defaultAccess` which is a function that returns `true` if the user is logged in.
106+
*/
107+
const accessFn = payload.config.jobs?.access?.queue ?? (() => true)
108+
const hasAccess = await accessFn({ req })
109+
if (!hasAccess) {
110+
throw new Forbidden(req.t)
111+
}
112+
}
113+
82114
let queue: string | undefined = undefined
83115

84116
// If user specifies queue, use that
@@ -123,15 +155,16 @@ export const getJobsLocalAPI = (payload: Payload) => ({
123155
collection: jobsCollectionSlug,
124156
data,
125157
depth: payload.config.jobs.depth ?? 0,
126-
req: args.req,
158+
overrideAccess,
159+
req,
127160
})) as ReturnType
128161
} else {
129162
return jobAfterRead({
130163
config: payload.config,
131164
doc: await payload.db.create({
132165
collection: jobsCollectionSlug,
133166
data,
134-
req: args.req,
167+
req,
135168
}),
136169
}) as unknown as ReturnType
137170
}
@@ -151,6 +184,14 @@ export const getJobsLocalAPI = (payload: Payload) => ({
151184
* @default 10
152185
*/
153186
limit?: number
187+
/**
188+
* If set to false, access control as defined in jobsConfig.access.run will be run.
189+
* By default, this is true and no access control will be run.
190+
* If you set this to false and do not have jobsConfig.access.run defined, the default access control will be
191+
* run (which is a function that returns `true` if the user is logged in).
192+
*
193+
* @default true
194+
*/
154195
overrideAccess?: boolean
155196
/**
156197
* Adjust the job processing order using a Payload sort string.
@@ -198,6 +239,14 @@ export const getJobsLocalAPI = (payload: Payload) => ({
198239

199240
runByID: async (args: {
200241
id: number | string
242+
/**
243+
* If set to false, access control as defined in jobsConfig.access.run will be run.
244+
* By default, this is true and no access control will be run.
245+
* If you set this to false and do not have jobsConfig.access.run defined, the default access control will be
246+
* run (which is a function that returns `true` if the user is logged in).
247+
*
248+
* @default true
249+
*/
201250
overrideAccess?: boolean
202251
req?: PayloadRequest
203252
/**
@@ -221,12 +270,32 @@ export const getJobsLocalAPI = (payload: Payload) => ({
221270
},
222271

223272
cancel: async (args: {
273+
/**
274+
* If set to false, access control as defined in jobsConfig.access.cancel will be run.
275+
* By default, this is true and no access control will be run.
276+
* If you set this to false and do not have jobsConfig.access.cancel defined, the default access control will be
277+
* run (which is a function that returns `true` if the user is logged in).
278+
*
279+
* @default true
280+
*/
224281
overrideAccess?: boolean
225282
queue?: string
226283
req?: PayloadRequest
227284
where: Where
228285
}): Promise<void> => {
229-
const newReq: PayloadRequest = args.req ?? (await createLocalReq({}, payload))
286+
const req: PayloadRequest = args.req ?? (await createLocalReq({}, payload))
287+
288+
const overrideAccess = args.overrideAccess !== false
289+
if (!overrideAccess) {
290+
/**
291+
* By default, jobsConfig.access.cancel will be `defaultAccess` which is a function that returns `true` if the user is logged in.
292+
*/
293+
const accessFn = payload.config.jobs?.access?.cancel ?? (() => true)
294+
const hasAccess = await accessFn({ req })
295+
if (!hasAccess) {
296+
throw new Forbidden(req.t)
297+
}
298+
}
230299

231300
const and: Where[] = [
232301
args.where,
@@ -262,18 +331,38 @@ export const getJobsLocalAPI = (payload: Payload) => ({
262331
},
263332
depth: 0, // No depth, since we're not returning
264333
disableTransaction: true,
265-
req: newReq,
334+
req,
266335
returning: false,
267336
where: { and },
268337
})
269338
},
270339

271340
cancelByID: async (args: {
272341
id: number | string
342+
/**
343+
* If set to false, access control as defined in jobsConfig.access.cancel will be run.
344+
* By default, this is true and no access control will be run.
345+
* If you set this to false and do not have jobsConfig.access.cancel defined, the default access control will be
346+
* run (which is a function that returns `true` if the user is logged in).
347+
*
348+
* @default true
349+
*/
273350
overrideAccess?: boolean
274351
req?: PayloadRequest
275352
}): Promise<void> => {
276-
const newReq: PayloadRequest = args.req ?? (await createLocalReq({}, payload))
353+
const req: PayloadRequest = args.req ?? (await createLocalReq({}, payload))
354+
355+
const overrideAccess = args.overrideAccess !== false
356+
if (!overrideAccess) {
357+
/**
358+
* By default, jobsConfig.access.cancel will be `defaultAccess` which is a function that returns `true` if the user is logged in.
359+
*/
360+
const accessFn = payload.config.jobs?.access?.cancel ?? (() => true)
361+
const hasAccess = await accessFn({ req })
362+
if (!hasAccess) {
363+
throw new Forbidden(req.t)
364+
}
365+
}
277366

278367
await updateJob({
279368
id: args.id,
@@ -288,7 +377,7 @@ export const getJobsLocalAPI = (payload: Payload) => ({
288377
},
289378
depth: 0, // No depth, since we're not returning
290379
disableTransaction: true,
291-
req: newReq,
380+
req,
292381
returning: false,
293382
})
294383
},

packages/payload/src/queues/operations/runJobs/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ export const runJobs = async (args: RunJobsArgs): Promise<RunJobsResult> => {
100100
} = args
101101

102102
if (!overrideAccess) {
103+
/**
104+
* By default, jobsConfig.access.run will be `defaultAccess` which is a function that returns `true` if the user is logged in.
105+
*/
103106
const accessFn = jobsConfig?.access?.run ?? (() => true)
104107
const hasAccess = await accessFn({ req })
105108
if (!hasAccess) {

0 commit comments

Comments
 (0)