From 63850bf5aeb7895806512a5da55c41dcd016db99 Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Sun, 6 Sep 2026 17:03:47 +0530 Subject: [PATCH] scope job status and cancel to the owner or an admin --- src/lib/server/jobs.ts | 20 +++++- src/routes/api/jobs/[id]/+server.ts | 19 ++++-- tests/jobs-owner-access.test.ts | 96 +++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 tests/jobs-owner-access.test.ts diff --git a/src/lib/server/jobs.ts b/src/lib/server/jobs.ts index 3bd692d..5484c44 100644 --- a/src/lib/server/jobs.ts +++ b/src/lib/server/jobs.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'crypto'; +import { getRequestContext } from './request-context'; export interface JobLine { event?: string; // 'result', 'progress', etc. — undefined for bare data lines @@ -14,6 +15,8 @@ export interface Job { updatedAt: number; /** Set when a client requests cancellation; long-running jobs should poll this. */ cancelRequested?: boolean; + /** User id that created the job, captured from the request context. null when auth is disabled. */ + ownerId: number | null; } const jobs = new Map(); @@ -24,12 +27,27 @@ export function createJob(): Job { status: 'running', lines: [], createdAt: Date.now(), - updatedAt: Date.now() + updatedAt: Date.now(), + ownerId: getRequestContext()?.user?.id ?? null }; jobs.set(job.id, job); return job; } +/** + * Whether the current request may read/cancel this job. Owner or admin only. + * When auth is disabled (or the job has no owner) access is open, matching the + * rest of the app's initial-setup behavior. + */ +export function canAccessJob(job: Job): boolean { + const ctx = getRequestContext(); + if (!ctx || !ctx.authEnabled) return true; + if (job.ownerId === null) return true; + const user = ctx.user; + if (!user) return false; + return user.isAdmin || user.id === job.ownerId; +} + export function getJob(id: string): Job | undefined { return jobs.get(id); } diff --git a/src/routes/api/jobs/[id]/+server.ts b/src/routes/api/jobs/[id]/+server.ts index 6ee2939..39ac178 100644 --- a/src/routes/api/jobs/[id]/+server.ts +++ b/src/routes/api/jobs/[id]/+server.ts @@ -1,22 +1,25 @@ import { json } from '@sveltejs/kit'; -import { getJob, cancelJob } from '$lib/server/jobs'; +import { getJob, cancelJob, canAccessJob } from '$lib/server/jobs'; import type { RequestHandler } from './$types'; /** * GET /api/jobs/[id] * Poll a job's status and accumulated lines. * Returns all lines every time — client tracks its own cursor locally. + * + * Authenticated via the global hook. Only the user who created the job (or an + * admin) may read it; others get a 404 so a job's existence is not revealed. */ /** * @openapi - * summary: Poll a background job's status and accumulated output lines (no auth — job ids are unguessable UUIDs) + * summary: Poll a background job's status and accumulated output lines (owner or admin only) * path: id:string! Job id (UUID) * resp-200: {id:string!, status:string!, lines:array<{event:string, data:{}}>!, result:{}} * resp-404: Job not found */ export const GET: RequestHandler = async ({ params }) => { const job = getJob(params.id); - if (!job) { + if (!job || !canAccessJob(job)) { return json({ error: 'Job not found' }, { status: 404 }); } @@ -33,12 +36,20 @@ export const GET: RequestHandler = async ({ params }) => { * Request cancellation of a running job. The job's operation polls the flag * between units of work and stops gracefully. * + * Only the user who created the job (or an admin) may cancel it; others get a + * 404 so a job's existence is not revealed. + * * @openapi - * summary: Request cancellation of a running background job (no auth — job ids are unguessable UUIDs) + * summary: Request cancellation of a running background job (owner or admin only) * path: id:string! Job id (UUID) * resp-200: {cancelled:boolean!} + * resp-404: Job not found */ export const DELETE: RequestHandler = async ({ params }) => { + const job = getJob(params.id); + if (!job || !canAccessJob(job)) { + return json({ error: 'Job not found' }, { status: 404 }); + } const cancelled = cancelJob(params.id); return json({ cancelled }); }; diff --git a/tests/jobs-owner-access.test.ts b/tests/jobs-owner-access.test.ts new file mode 100644 index 0000000..2f1cdf3 --- /dev/null +++ b/tests/jobs-owner-access.test.ts @@ -0,0 +1,96 @@ +/** + * Job ownership access control (canAccessJob). + * + * A job belongs to the user who created it (ownerId captured from the request + * context). Only that user or an admin may read/cancel it. With auth disabled, + * or for a job with no owner, access is open. + */ + +import { describe, test, expect } from 'bun:test'; +import { createJob, canAccessJob, type Job } from '../src/lib/server/jobs'; +import { requestContext, type RequestContext } from '../src/lib/server/request-context'; + +function ctx(over: Partial): RequestContext { + return { user: null, authEnabled: true, authMethod: 'cookie', ...over }; +} + +function user(id: number, isAdmin = false) { + return { + id, + username: `u${id}`, + isAdmin, + provider: 'local' as const, + permissions: {} as any + }; +} + +function jobOwnedBy(ownerId: number | null): Job { + return { + id: 'j', + status: 'running', + lines: [], + createdAt: 0, + updatedAt: 0, + ownerId + }; +} + +describe('createJob captures the owner from request context', () => { + test('ownerId = current user id inside a request', () => { + const job = requestContext.run(ctx({ user: user(7) }), () => createJob()); + expect(job.ownerId).toBe(7); + }); + + test('ownerId = null when there is no request context', () => { + const job = createJob(); + expect(job.ownerId).toBeNull(); + }); + + test('ownerId = null when auth is disabled', () => { + const job = requestContext.run(ctx({ user: null, authEnabled: false }), () => createJob()); + expect(job.ownerId).toBeNull(); + }); +}); + +describe('canAccessJob', () => { + test('owner can access their own job', () => { + const job = jobOwnedBy(7); + const ok = requestContext.run(ctx({ user: user(7) }), () => canAccessJob(job)); + expect(ok).toBe(true); + }); + + test('admin can access another user\'s job', () => { + const job = jobOwnedBy(7); + const ok = requestContext.run(ctx({ user: user(99, true) }), () => canAccessJob(job)); + expect(ok).toBe(true); + }); + + test('a different non-admin user is denied', () => { + const job = jobOwnedBy(7); + const ok = requestContext.run(ctx({ user: user(8) }), () => canAccessJob(job)); + expect(ok).toBe(false); + }); + + test('an unauthenticated request (auth on) is denied an owned job', () => { + const job = jobOwnedBy(7); + const ok = requestContext.run(ctx({ user: null }), () => canAccessJob(job)); + expect(ok).toBe(false); + }); + + test('auth disabled: access is open even to an owned job', () => { + const job = jobOwnedBy(7); + const ok = requestContext.run(ctx({ user: null, authEnabled: false }), () => canAccessJob(job)); + expect(ok).toBe(true); + }); + + test('a job with no owner is open (auth enabled)', () => { + const job = jobOwnedBy(null); + const ok = requestContext.run(ctx({ user: user(8) }), () => canAccessJob(job)); + expect(ok).toBe(true); + }); + + test('no request context at all: access is open', () => { + const job = jobOwnedBy(7); + expect(canAccessJob(job)).toBe(true); + }); +});