diff --git a/docs/jobs-queue/jobs.mdx b/docs/jobs-queue/jobs.mdx index 8b24a868593..336c2a2332f 100644 --- a/docs/jobs-queue/jobs.mdx +++ b/docs/jobs-queue/jobs.mdx @@ -47,6 +47,51 @@ const createdJob = await payload.jobs.queue({ }) ``` +#### Access Control + +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. + +To define custom access control for jobs, add an `access` property to your Jobs Config: + +```ts +import type { SanitizedConfig } from 'payload' + +const config: SanitizedConfig = { + // ... + jobs: { + access: { + // Control who can queue new jobs + queue: ({ req }) => { + return req.user?.roles?.includes('admin') + }, + // Control who can run jobs + run: ({ req }) => { + return req.user?.roles?.includes('admin') + }, + // Control who can cancel jobs + cancel: ({ req }) => { + return req.user?.roles?.includes('admin') + }, + }, + }, +} +``` + +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. + +To use access control in the Local API: + +```ts +const req = await createLocalReq({ user }, payload) + +await payload.jobs.queue({ + workflow: 'createPost', + input: { title: 'My Post' }, + overrideAccess: false, // Enable access control + req, // Pass the request with user context +}) +``` + #### Cancelling Jobs 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. diff --git a/packages/payload/src/config/defaults.ts b/packages/payload/src/config/defaults.ts index 77dc94677c7..7d8ee9b4ae4 100644 --- a/packages/payload/src/config/defaults.ts +++ b/packages/payload/src/config/defaults.ts @@ -57,6 +57,8 @@ export const defaults: Omit = { i18n: {}, jobs: { access: { + cancel: defaultAccess, + queue: defaultAccess, run: defaultAccess, }, deleteJobOnComplete: true, @@ -136,6 +138,8 @@ export const addDefaultsToConfig = (config: Config): Config => { depth: 0, ...(config.jobs || {}), access: { + cancel: defaultAccess, + queue: defaultAccess, run: defaultAccess, ...(config.jobs?.access || {}), }, diff --git a/packages/payload/src/queues/config/types/index.ts b/packages/payload/src/queues/config/types/index.ts index 6a5de95a353..0e5e7699e43 100644 --- a/packages/payload/src/queues/config/types/index.ts +++ b/packages/payload/src/queues/config/types/index.ts @@ -71,6 +71,16 @@ export type RunJobAccessArgs = { export type RunJobAccess = (args: RunJobAccessArgs) => boolean | Promise +export type QueueJobAccessArgs = { + req: PayloadRequest +} + +export type CancelJobAccessArgs = { + req: PayloadRequest +} +export type CancelJobAccess = (args: CancelJobAccessArgs) => boolean | Promise +export type QueueJobAccess = (args: QueueJobAccessArgs) => boolean | Promise + export type SanitizedJobsConfig = { /** * If set to `true`, the job system is enabled and a payload-jobs collection exists. @@ -94,7 +104,15 @@ export type JobsConfig = { */ access?: { /** - * By default, all logged-in users can trigger jobs. + * By default, all logged-in users can cancel jobs. + */ + cancel?: CancelJobAccess + /** + * By default, all logged-in users can queue jobs. + */ + queue?: QueueJobAccess + /** + * By default, all logged-in users can run jobs. */ run?: RunJobAccess } diff --git a/packages/payload/src/queues/localAPI.ts b/packages/payload/src/queues/localAPI.ts index 732f0970ed9..8f59e6456c2 100644 --- a/packages/payload/src/queues/localAPI.ts +++ b/packages/payload/src/queues/localAPI.ts @@ -2,6 +2,7 @@ import type { BaseJob, RunningJobFromTask } from './config/types/workflowTypes.j import { createLocalReq, + Forbidden, type Job, type Payload, type PayloadRequest, @@ -56,9 +57,17 @@ export const getJobsLocalAPI = (payload: Payload) => ({ | { input: TypedJobs['tasks'][TTaskOrWorkflowSlug]['input'] meta?: BaseJob['meta'] + /** + * If set to false, access control as defined in jobsConfig.access.queue will be run. + * By default, this is true and no access control will be run. + * If you set this to false and do not have jobsConfig.access.queue defined, the default access control will be + * run (which is a function that returns `true` if the user is logged in). + * + * @default true + */ + overrideAccess?: boolean queue?: string req?: PayloadRequest - // TTaskOrWorkflowlug with keyof TypedJobs['workflows'] removed: task: TTaskOrWorkflowSlug extends keyof TypedJobs['tasks'] ? TTaskOrWorkflowSlug : never waitUntil?: Date workflow?: never @@ -66,6 +75,15 @@ export const getJobsLocalAPI = (payload: Payload) => ({ | { input: TypedJobs['workflows'][TTaskOrWorkflowSlug]['input'] meta?: BaseJob['meta'] + /** + * If set to false, access control as defined in jobsConfig.access.queue will be run. + * By default, this is true and no access control will be run. + * If you set this to false and do not have jobsConfig.access.queue defined, the default access control will be + * run (which is a function that returns `true` if the user is logged in). + * + * @default true + */ + overrideAccess?: boolean queue?: string req?: PayloadRequest task?: never @@ -79,6 +97,20 @@ export const getJobsLocalAPI = (payload: Payload) => ({ ? Job : RunningJobFromTask > => { + const overrideAccess = args?.overrideAccess !== false + const req: PayloadRequest = args.req ?? (await createLocalReq({}, payload)) + + if (!overrideAccess) { + /** + * By default, jobsConfig.access.queue will be `defaultAccess` which is a function that returns `true` if the user is logged in. + */ + const accessFn = payload.config.jobs?.access?.queue ?? (() => true) + const hasAccess = await accessFn({ req }) + if (!hasAccess) { + throw new Forbidden(req.t) + } + } + let queue: string | undefined = undefined // If user specifies queue, use that @@ -123,7 +155,8 @@ export const getJobsLocalAPI = (payload: Payload) => ({ collection: jobsCollectionSlug, data, depth: payload.config.jobs.depth ?? 0, - req: args.req, + overrideAccess, + req, })) as ReturnType } else { return jobAfterRead({ @@ -131,7 +164,7 @@ export const getJobsLocalAPI = (payload: Payload) => ({ doc: await payload.db.create({ collection: jobsCollectionSlug, data, - req: args.req, + req, }), }) as unknown as ReturnType } @@ -151,6 +184,14 @@ export const getJobsLocalAPI = (payload: Payload) => ({ * @default 10 */ limit?: number + /** + * If set to false, access control as defined in jobsConfig.access.run will be run. + * By default, this is true and no access control will be run. + * If you set this to false and do not have jobsConfig.access.run defined, the default access control will be + * run (which is a function that returns `true` if the user is logged in). + * + * @default true + */ overrideAccess?: boolean /** * Adjust the job processing order using a Payload sort string. @@ -198,6 +239,14 @@ export const getJobsLocalAPI = (payload: Payload) => ({ runByID: async (args: { id: number | string + /** + * If set to false, access control as defined in jobsConfig.access.run will be run. + * By default, this is true and no access control will be run. + * If you set this to false and do not have jobsConfig.access.run defined, the default access control will be + * run (which is a function that returns `true` if the user is logged in). + * + * @default true + */ overrideAccess?: boolean req?: PayloadRequest /** @@ -221,12 +270,32 @@ export const getJobsLocalAPI = (payload: Payload) => ({ }, cancel: async (args: { + /** + * If set to false, access control as defined in jobsConfig.access.cancel will be run. + * By default, this is true and no access control will be run. + * If you set this to false and do not have jobsConfig.access.cancel defined, the default access control will be + * run (which is a function that returns `true` if the user is logged in). + * + * @default true + */ overrideAccess?: boolean queue?: string req?: PayloadRequest where: Where }): Promise => { - const newReq: PayloadRequest = args.req ?? (await createLocalReq({}, payload)) + const req: PayloadRequest = args.req ?? (await createLocalReq({}, payload)) + + const overrideAccess = args.overrideAccess !== false + if (!overrideAccess) { + /** + * By default, jobsConfig.access.cancel will be `defaultAccess` which is a function that returns `true` if the user is logged in. + */ + const accessFn = payload.config.jobs?.access?.cancel ?? (() => true) + const hasAccess = await accessFn({ req }) + if (!hasAccess) { + throw new Forbidden(req.t) + } + } const and: Where[] = [ args.where, @@ -262,7 +331,7 @@ export const getJobsLocalAPI = (payload: Payload) => ({ }, depth: 0, // No depth, since we're not returning disableTransaction: true, - req: newReq, + req, returning: false, where: { and }, }) @@ -270,10 +339,30 @@ export const getJobsLocalAPI = (payload: Payload) => ({ cancelByID: async (args: { id: number | string + /** + * If set to false, access control as defined in jobsConfig.access.cancel will be run. + * By default, this is true and no access control will be run. + * If you set this to false and do not have jobsConfig.access.cancel defined, the default access control will be + * run (which is a function that returns `true` if the user is logged in). + * + * @default true + */ overrideAccess?: boolean req?: PayloadRequest }): Promise => { - const newReq: PayloadRequest = args.req ?? (await createLocalReq({}, payload)) + const req: PayloadRequest = args.req ?? (await createLocalReq({}, payload)) + + const overrideAccess = args.overrideAccess !== false + if (!overrideAccess) { + /** + * By default, jobsConfig.access.cancel will be `defaultAccess` which is a function that returns `true` if the user is logged in. + */ + const accessFn = payload.config.jobs?.access?.cancel ?? (() => true) + const hasAccess = await accessFn({ req }) + if (!hasAccess) { + throw new Forbidden(req.t) + } + } await updateJob({ id: args.id, @@ -288,7 +377,7 @@ export const getJobsLocalAPI = (payload: Payload) => ({ }, depth: 0, // No depth, since we're not returning disableTransaction: true, - req: newReq, + req, returning: false, }) }, diff --git a/packages/payload/src/queues/operations/runJobs/index.ts b/packages/payload/src/queues/operations/runJobs/index.ts index 9530594788e..6c982bb538a 100644 --- a/packages/payload/src/queues/operations/runJobs/index.ts +++ b/packages/payload/src/queues/operations/runJobs/index.ts @@ -100,6 +100,9 @@ export const runJobs = async (args: RunJobsArgs): Promise => { } = args if (!overrideAccess) { + /** + * By default, jobsConfig.access.run will be `defaultAccess` which is a function that returns `true` if the user is logged in. + */ const accessFn = jobsConfig?.access?.run ?? (() => true) const hasAccess = await accessFn({ req }) if (!hasAccess) { diff --git a/test/queues/int.spec.ts b/test/queues/int.spec.ts index edcc30b90c6..a88ab501264 100644 --- a/test/queues/int.spec.ts +++ b/test/queues/int.spec.ts @@ -2,8 +2,11 @@ import path from 'path' import { _internal_jobSystemGlobals, _internal_resetJobSystemGlobals, + createLocalReq, + Forbidden, type JobTaskStatus, type Payload, + type TypedUser, } from 'payload' import { wait } from 'payload/shared' import { fileURLToPath } from 'url' @@ -23,6 +26,7 @@ describe('Queues - Payload', () => { let payload: Payload let restClient: NextRESTClient let token: string + let user: TypedUser beforeAll(async () => { process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit @@ -62,28 +66,231 @@ describe('Queues - Payload', () => { if (data.token) { token = data.token } + user = data.user payload.config.jobs.deleteJobOnComplete = true _internal_jobSystemGlobals.shouldAutoRun = true _internal_jobSystemGlobals.shouldAutoSchedule = true }) - it('will run access control on jobs runner', async () => { - const response = await restClient.GET('/payload-jobs/run?silent=true', { - headers: { - // Authorization: `JWT ${token}`, - }, - }) // Needs to be a rest call to test auth - expect(response.status).toBe(401) - }) + describe('access control', () => { + it('will run access control on jobs runner run endpoint', async () => { + const response = await restClient.GET('/payload-jobs/run?silent=true', { + headers: { + // Authorization: `JWT ${token}`, + }, + }) // Needs to be a rest call to test auth + expect(response.status).toBe(401) + }) + it('will return 200 from jobs runner', async () => { + const response = await restClient.GET('/payload-jobs/run?silent=true', { + headers: { + Authorization: `JWT ${token}`, + }, + }) // Needs to be a rest call to test auth - it('will return 200 from jobs runner', async () => { - const response = await restClient.GET('/payload-jobs/run?silent=true', { - headers: { - Authorization: `JWT ${token}`, - }, - }) // Needs to be a rest call to test auth + expect(response.status).toBe(200) + }) + + it('will fail access control on local api .queue when passing overrideAccess: false', async () => { + await expect( + payload.jobs.queue({ + task: 'CreateSimple', + input: { + message: 'from single task', + }, + overrideAccess: false, + }), + ).rejects.toThrow(Forbidden) + }) + + it('will pass access control on local api .queue when passing overrideAccess: false', async () => { + const req = await createLocalReq({ user }, payload) + const result = await payload.jobs.queue({ + task: 'CreateSimple', + input: { + message: 'from single task', + }, + overrideAccess: false, + req, + }) + + expect(result).toBeDefined() + expect(result.input.message).toBe('from single task') + }) + + it('will fail access control on local api .run when passing overrideAccess: false', async () => { + await expect( + payload.jobs.run({ + overrideAccess: false, + }), + ).rejects.toThrow(Forbidden) + }) + + it('will pass access control on local api .run when passing overrideAccess: false', async () => { + const req = await createLocalReq({ user }, payload) + const result = await payload.jobs.run({ + overrideAccess: false, + req, + }) - expect(response.status).toBe(200) + expect(result).toBeDefined() + }) + + it('will fail access control on local api .runByID when passing overrideAccess: false', async () => { + await expect( + payload.jobs.runByID({ + id: '1', + overrideAccess: false, + }), + ).rejects.toThrow(Forbidden) + }) + + it('will pass access control on local api .runByID when passing overrideAccess: false', async () => { + const req = await createLocalReq({ user }, payload) + + // Queue a job first so we have a valid ID + const job = await payload.jobs.queue({ + task: 'CreateSimple', + input: { + message: 'from single task', + }, + }) + + const result = await payload.jobs.runByID({ + id: job.id, + overrideAccess: false, + req, + silent: true, + }) + + expect(result).toBeDefined() + }) + + it('will fail access control on local api .cancel when passing overrideAccess: false', async () => { + payload.config.jobs.deleteJobOnComplete = false + + // Queue a job without running it + const job = await payload.jobs.queue({ + task: 'CreateSimple', + input: { + message: 'from single task', + }, + }) + + await expect( + payload.jobs.cancel({ + where: { + id: { + equals: job.id, + }, + }, + overrideAccess: false, + }), + ).rejects.toThrow(Forbidden) + + // Verify the job was NOT cancelled + const jobAfterCancel = await payload.findByID({ + collection: 'payload-jobs', + id: job.id, + }) + + expect(jobAfterCancel.hasError).toBe(false) + // @ts-expect-error error is not typed + expect(jobAfterCancel.error?.cancelled).toBeUndefined() + }) + + it('will pass access control on local api .cancel when passing overrideAccess: false', async () => { + payload.config.jobs.deleteJobOnComplete = false + + const req = await createLocalReq({ user }, payload) + + // Queue a job without running it + const job = await payload.jobs.queue({ + task: 'CreateSimple', + input: { + message: 'from single task', + }, + }) + + await payload.jobs.cancel({ + where: { + id: { + equals: job.id, + }, + }, + overrideAccess: false, + req, + }) + + // Verify the job was cancelled + const jobAfterCancel = await payload.findByID({ + collection: 'payload-jobs', + id: job.id, + }) + + expect(jobAfterCancel.hasError).toBe(true) + // @ts-expect-error error is not typed + expect(jobAfterCancel.error?.cancelled).toBe(true) + }) + + it('will fail access control on local api .cancelByID when passing overrideAccess: false', async () => { + payload.config.jobs.deleteJobOnComplete = false + + // Queue a job without running it + const job = await payload.jobs.queue({ + task: 'CreateSimple', + input: { + message: 'from single task', + }, + }) + + await expect( + payload.jobs.cancelByID({ + id: job.id, + overrideAccess: false, + }), + ).rejects.toThrow(Forbidden) + + // Verify the job was NOT cancelled + const jobAfterCancel = await payload.findByID({ + collection: 'payload-jobs', + id: job.id, + }) + + expect(jobAfterCancel.hasError).toBe(false) + // @ts-expect-error error is not typed + expect(jobAfterCancel.error?.cancelled).toBeUndefined() + }) + + it('will pass access control on local api .cancelByID when passing overrideAccess: false', async () => { + payload.config.jobs.deleteJobOnComplete = false + + const req = await createLocalReq({ user }, payload) + + // Queue a job without running it + const job = await payload.jobs.queue({ + task: 'CreateSimple', + input: { + message: 'from single task', + }, + }) + + await payload.jobs.cancelByID({ + id: job.id, + overrideAccess: false, + req, + }) + + // Verify the job was cancelled + const jobAfterCancel = await payload.findByID({ + collection: 'payload-jobs', + id: job.id, + }) + + expect(jobAfterCancel.hasError).toBe(true) + // @ts-expect-error error is not typed + expect(jobAfterCancel.error?.cancelled).toBe(true) + }) }) // There used to be a bug in payload where updating the job threw the following error - only in