scope job status and cancel to the owner or an admin
This commit is contained in:
3 files changed
+130
-5
No files matched your search
+19
-1
@@ -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<string, Job>();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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>): 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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user