diff --git a/src/lib/provisioning-detail.ts b/src/lib/provisioning-detail.ts index 1b5b1ea..c11c358 100644 --- a/src/lib/provisioning-detail.ts +++ b/src/lib/provisioning-detail.ts @@ -71,6 +71,7 @@ export interface ProvisioningDeployment { checked_at?: string | null; players?: number | null; triggered_by: string; + requested_by?: string | null; error: string | null; warnings: string; server_exists: number; diff --git a/src/lib/server/auth/guard.ts b/src/lib/server/auth/guard.ts index d8a60ec..43085f7 100644 --- a/src/lib/server/auth/guard.ts +++ b/src/lib/server/auth/guard.ts @@ -2,6 +2,7 @@ import { error, json, redirect, type Handle } from '@sveltejs/kit'; import { readSession, readSessionHash, recordAudit, setupRequired } from './index.js'; import { authBody, authError, requireSameOrigin, requireUserTransport, sessionCookie } from './http.js'; import { roleAllows, routePermission, type RoutePermission } from './access.js'; +import { withRequestUsername } from './request-context.js'; function publicAsset(routeId: string | null, path: string, method: string) { return routeId === null && ['GET','HEAD'].includes(method) && @@ -94,7 +95,7 @@ export const authenticationHandle: Handle = async ({ event, resolve }) => { if (routeId==='/login'&&setup) redirect(303,'/setup'); } - let response = await resolve(event); + let response = await withRequestUsername(session?.user.username ?? null, () => resolve(event)); if (session && !['GET','HEAD','OPTIONS'].includes(method) && response.status < 400) { recordAudit(session.user.id,'request',routeId,{method,status:response.status,path:event.url.pathname}); } diff --git a/src/lib/server/auth/request-context.ts b/src/lib/server/auth/request-context.ts new file mode 100644 index 0000000..65a2ce4 --- /dev/null +++ b/src/lib/server/auth/request-context.ts @@ -0,0 +1,17 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +const requester = new AsyncLocalStorage<{ username: string | null; active: boolean }>(); + +/** Use only the authenticated session, and stop attribution when the request finishes. */ +export async function withRequestUsername(username: string | null, work: () => T | Promise): Promise { + const context = { username, active: true }; + return requester.run(context, async () => { + try { return await work(); } + finally { context.active = false; } + }); +} + +export function currentRequestUsername(): string | null { + const context = requester.getStore(); + return context?.active ? context.username : null; +} diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 0663a2d..2af3902 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -334,6 +334,7 @@ export function db(): Database.Database { ]); migrateTable(handle, 'restart_rollout', [{ name: 'operation_id', ddl: 'ALTER TABLE restart_rollout ADD COLUMN operation_id TEXT' }]); migrateTable(handle, 'server_deployments', [ + { name: 'requested_by', ddl: 'ALTER TABLE server_deployments ADD COLUMN requested_by TEXT' }, { name: 'submitted_at', ddl: 'ALTER TABLE server_deployments ADD COLUMN submitted_at TEXT' }, { name: 'checked_at', ddl: 'ALTER TABLE server_deployments ADD COLUMN checked_at TEXT' }, { name: 'players', ddl: 'ALTER TABLE server_deployments ADD COLUMN players INTEGER' }, diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index e68949d..732e562 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -503,6 +503,7 @@ CREATE TABLE IF NOT EXISTS server_deployments ( started_at TEXT NOT NULL, finished_at TEXT, triggered_by TEXT NOT NULL, + requested_by TEXT, error TEXT, warnings TEXT NOT NULL DEFAULT '[]', phase_key TEXT, diff --git a/src/lib/server/provisioning/history.ts b/src/lib/server/provisioning/history.ts index 1d275b1..5040c12 100644 --- a/src/lib/server/provisioning/history.ts +++ b/src/lib/server/provisioning/history.ts @@ -1,4 +1,5 @@ import { pauseScalingForOperation } from '../autoscaling/ownership.js'; +import { currentRequestUsername } from '../auth/request-context.js'; import { randomUUID } from 'node:crypto'; import { db } from '../db/index.js'; import { recipeSummaryForDeployment } from '../fleet/launch-recipes.js'; @@ -19,8 +20,9 @@ import { provisioningPageSize, provisioningPageOffset, type ProvisioningListOpti export function beginDeployment(server: string, action: ProvisioningAction, triggeredBy = 'ui'): string { const id = randomUUID(); db().transaction(() => { - db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by) - VALUES (?, ?, ?, 'running', 'Starting', ?, ?)`).run(id, server, action, new Date().toISOString(), triggeredBy); + db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by, requested_by) + VALUES (?, ?, ?, 'running', 'Starting', ?, ?, ?)`).run(id, server, action, new Date().toISOString(), triggeredBy, + triggeredBy === 'ui' ? currentRequestUsername() : null); bindNativeOperation(id, server); recordProgress(id, { phase: 'requested', observedAt: null, checkpoint: { key: 'requested', phase: 'requested', label: action === 'create' ? 'Server creation requested' diff --git a/src/lib/server/provisioning/requests.ts b/src/lib/server/provisioning/requests.ts index fefc355..081ef93 100644 --- a/src/lib/server/provisioning/requests.ts +++ b/src/lib/server/provisioning/requests.ts @@ -1,4 +1,5 @@ import { scalingOwnershipBlocker } from '../autoscaling/ownership.js'; +import { currentRequestUsername } from '../auth/request-context.js'; import { maintenanceAllowed } from '../maintenance/policies.js'; import { db } from '../db/index.js'; import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js'; @@ -140,7 +141,8 @@ export function requestLifecycle(server: string, action: RuntimeAction) { WHERE s.operation_id = ? AND s.kind = 'watchdog' AND i.kind = 'health'`).get(existing.id)) { // An operator explicitly requesting this queued action keeps // that intent even if the original health condition clears. - db().prepare("UPDATE server_deployments SET triggered_by = 'ui' WHERE id = ?").run(existing.id); + db().prepare("UPDATE server_deployments SET triggered_by = 'ui', requested_by = ? WHERE id = ?") + .run(currentRequestUsername(), existing.id); recordDeploymentEvent(existing.id, 'Operator explicitly requested the queued health recovery action'); } return { ok: true as const, deploymentId: existing.id }; diff --git a/src/lib/ui/provisioning-presentation.ts b/src/lib/ui/provisioning-presentation.ts index c471255..420c6f9 100644 --- a/src/lib/ui/provisioning-presentation.ts +++ b/src/lib/ui/provisioning-presentation.ts @@ -168,6 +168,7 @@ export function isProvisioningDetail(value: unknown, id: string): value is Provi const operation = value.deployment; if (operation.id !== id || !provisioningActions.includes(operation.action as ProvisioningAction) || !['server', 'state', 'stage', 'triggered_by', 'warnings'].every(key => text(operation[key])) + || (operation.requested_by !== undefined && !nullableText(operation.requested_by)) || !timestamp(operation.started_at) || !nullableTime(operation.finished_at) || !nullableTime(operation.server_deleted_at) || !nullableText(operation.error) || !number(operation.server_exists) || (operation.submitted_at !== undefined && !nullableTime(operation.submitted_at)) diff --git a/src/routes/provisioning/[id]/+page.svelte b/src/routes/provisioning/[id]/+page.svelte index aa49735..08d9234 100644 --- a/src/routes/provisioning/[id]/+page.svelte +++ b/src/routes/provisioning/[id]/+page.svelte @@ -159,7 +159,7 @@ {/if}

Operation details