Record usernames for manual provisioning requests

This commit is contained in:
2026-10-08 00:15:19 -04:00
parent 6a897df716
commit 2f67482521
12 changed files with 122 additions and 5 deletions
+1
View File
@@ -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;
+2 -1
View File
@@ -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});
}
+17
View File
@@ -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<T>(username: string | null, work: () => T | Promise<T>): Promise<T> {
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;
}
+1
View File
@@ -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' },
+1
View File
@@ -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,
+4 -2
View File
@@ -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'
+3 -1
View File
@@ -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 };
+1
View File
@@ -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))