Record usernames for manual provisioning requests
This commit is contained in:
@@ -71,6 +71,7 @@ export interface ProvisioningDeployment {
|
|||||||
checked_at?: string | null;
|
checked_at?: string | null;
|
||||||
players?: number | null;
|
players?: number | null;
|
||||||
triggered_by: string;
|
triggered_by: string;
|
||||||
|
requested_by?: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
warnings: string;
|
warnings: string;
|
||||||
server_exists: number;
|
server_exists: number;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { error, json, redirect, type Handle } from '@sveltejs/kit';
|
|||||||
import { readSession, readSessionHash, recordAudit, setupRequired } from './index.js';
|
import { readSession, readSessionHash, recordAudit, setupRequired } from './index.js';
|
||||||
import { authBody, authError, requireSameOrigin, requireUserTransport, sessionCookie } from './http.js';
|
import { authBody, authError, requireSameOrigin, requireUserTransport, sessionCookie } from './http.js';
|
||||||
import { roleAllows, routePermission, type RoutePermission } from './access.js';
|
import { roleAllows, routePermission, type RoutePermission } from './access.js';
|
||||||
|
import { withRequestUsername } from './request-context.js';
|
||||||
|
|
||||||
function publicAsset(routeId: string | null, path: string, method: string) {
|
function publicAsset(routeId: string | null, path: string, method: string) {
|
||||||
return routeId === null && ['GET','HEAD'].includes(method) &&
|
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');
|
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) {
|
if (session && !['GET','HEAD','OPTIONS'].includes(method) && response.status < 400) {
|
||||||
recordAudit(session.user.id,'request',routeId,{method,status:response.status,path:event.url.pathname});
|
recordAudit(session.user.id,'request',routeId,{method,status:response.status,path:event.url.pathname});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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, 'restart_rollout', [{ name: 'operation_id', ddl: 'ALTER TABLE restart_rollout ADD COLUMN operation_id TEXT' }]);
|
||||||
migrateTable(handle, 'server_deployments', [
|
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: '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: 'checked_at', ddl: 'ALTER TABLE server_deployments ADD COLUMN checked_at TEXT' },
|
||||||
{ name: 'players', ddl: 'ALTER TABLE server_deployments ADD COLUMN players INTEGER' },
|
{ name: 'players', ddl: 'ALTER TABLE server_deployments ADD COLUMN players INTEGER' },
|
||||||
|
|||||||
@@ -503,6 +503,7 @@ CREATE TABLE IF NOT EXISTS server_deployments (
|
|||||||
started_at TEXT NOT NULL,
|
started_at TEXT NOT NULL,
|
||||||
finished_at TEXT,
|
finished_at TEXT,
|
||||||
triggered_by TEXT NOT NULL,
|
triggered_by TEXT NOT NULL,
|
||||||
|
requested_by TEXT,
|
||||||
error TEXT,
|
error TEXT,
|
||||||
warnings TEXT NOT NULL DEFAULT '[]',
|
warnings TEXT NOT NULL DEFAULT '[]',
|
||||||
phase_key TEXT,
|
phase_key TEXT,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { pauseScalingForOperation } from '../autoscaling/ownership.js';
|
import { pauseScalingForOperation } from '../autoscaling/ownership.js';
|
||||||
|
import { currentRequestUsername } from '../auth/request-context.js';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { recipeSummaryForDeployment } from '../fleet/launch-recipes.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 {
|
export function beginDeployment(server: string, action: ProvisioningAction, triggeredBy = 'ui'): string {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
db().transaction(() => {
|
db().transaction(() => {
|
||||||
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
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);
|
VALUES (?, ?, ?, 'running', 'Starting', ?, ?, ?)`).run(id, server, action, new Date().toISOString(), triggeredBy,
|
||||||
|
triggeredBy === 'ui' ? currentRequestUsername() : null);
|
||||||
bindNativeOperation(id, server);
|
bindNativeOperation(id, server);
|
||||||
recordProgress(id, { phase: 'requested', observedAt: null, checkpoint: { key: 'requested', phase: 'requested',
|
recordProgress(id, { phase: 'requested', observedAt: null, checkpoint: { key: 'requested', phase: 'requested',
|
||||||
label: action === 'create' ? 'Server creation requested'
|
label: action === 'create' ? 'Server creation requested'
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { scalingOwnershipBlocker } from '../autoscaling/ownership.js';
|
import { scalingOwnershipBlocker } from '../autoscaling/ownership.js';
|
||||||
|
import { currentRequestUsername } from '../auth/request-context.js';
|
||||||
import { maintenanceAllowed } from '../maintenance/policies.js';
|
import { maintenanceAllowed } from '../maintenance/policies.js';
|
||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.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)) {
|
WHERE s.operation_id = ? AND s.kind = 'watchdog' AND i.kind = 'health'`).get(existing.id)) {
|
||||||
// An operator explicitly requesting this queued action keeps
|
// An operator explicitly requesting this queued action keeps
|
||||||
// that intent even if the original health condition clears.
|
// 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');
|
recordDeploymentEvent(existing.id, 'Operator explicitly requested the queued health recovery action');
|
||||||
}
|
}
|
||||||
return { ok: true as const, deploymentId: existing.id };
|
return { ok: true as const, deploymentId: existing.id };
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export function isProvisioningDetail(value: unknown, id: string): value is Provi
|
|||||||
const operation = value.deployment;
|
const operation = value.deployment;
|
||||||
if (operation.id !== id || !provisioningActions.includes(operation.action as ProvisioningAction)
|
if (operation.id !== id || !provisioningActions.includes(operation.action as ProvisioningAction)
|
||||||
|| !['server', 'state', 'stage', 'triggered_by', 'warnings'].every(key => text(operation[key]))
|
|| !['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)
|
|| !timestamp(operation.started_at) || !nullableTime(operation.finished_at) || !nullableTime(operation.server_deleted_at)
|
||||||
|| !nullableText(operation.error) || !number(operation.server_exists)
|
|| !nullableText(operation.error) || !number(operation.server_exists)
|
||||||
|| (operation.submitted_at !== undefined && !nullableTime(operation.submitted_at))
|
|| (operation.submitted_at !== undefined && !nullableTime(operation.submitted_at))
|
||||||
|
|||||||
@@ -159,7 +159,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<Card as="section" padding="md" ariaLabel="Operation details">
|
<Card as="section" padding="md" ariaLabel="Operation details">
|
||||||
<div class="context-section"><h2>Operation details</h2><dl class="metadata">
|
<div class="context-section"><h2>Operation details</h2><dl class="metadata">
|
||||||
<div><dt>Requested by</dt><dd>{deployment.triggered_by}</dd></div>
|
<div><dt>Requested by</dt><dd>{deployment.requested_by ?? deployment.triggered_by}</dd></div>
|
||||||
<div><dt>Requested at</dt><dd><RelativeTime value={deployment.started_at} /></dd></div>
|
<div><dt>Requested at</dt><dd><RelativeTime value={deployment.started_at} /></dd></div>
|
||||||
<div><dt>Submission attempted</dt><dd><RelativeTime value={deployment.submitted_at} missing="Not recorded" /></dd></div>
|
<div><dt>Submission attempted</dt><dd><RelativeTime value={deployment.submitted_at} missing="Not recorded" /></dd></div>
|
||||||
<div><dt>Finished</dt><dd><RelativeTime value={deployment.finished_at} missing={outcome.active ? 'In progress' : 'Not recorded'} /></dd></div>
|
<div><dt>Finished</dt><dd><RelativeTime value={deployment.finished_at} missing={outcome.active ? 'In progress' : 'Not recorded'} /></dd></div>
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ beforeEach(() => {
|
|||||||
afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); });
|
afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); });
|
||||||
|
|
||||||
describe('browser authentication guard', () => {
|
describe('browser authentication guard', () => {
|
||||||
|
it('carries the session username through an authenticated action, ignoring requester fields from the client', async () => {
|
||||||
|
const { currentRequestUsername } = await import('../../src/lib/server/auth/request-context.js');
|
||||||
|
signIn('operator');
|
||||||
|
const call = requestFor('/api/provisioning', { method: 'POST', body: { requested_by: 'someone-else' },
|
||||||
|
resolve: async event => {
|
||||||
|
await event.request.json();
|
||||||
|
expect(currentRequestUsername()).toBe('tester');
|
||||||
|
return new Response('queued');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect((await call.invoke()).status).toBe(200);
|
||||||
|
expect(currentRequestUsername()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it.each(['GET', 'HEAD'])('allows anonymous leaderboard %s requests even before account setup', async method => {
|
it.each(['GET', 'HEAD'])('allows anonymous leaderboard %s requests even before account setup', async method => {
|
||||||
for (const setup of [false, true]) {
|
for (const setup of [false, true]) {
|
||||||
auth.setup = setup;
|
auth.setup = setup;
|
||||||
|
|||||||
@@ -142,6 +142,14 @@ describe('provisioning outcome and evidence presentation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('provisioning response and return-context boundaries', () => {
|
describe('provisioning response and return-context boundaries', () => {
|
||||||
|
it('accepts persisted usernames and legacy details but rejects malformed requesters', () => {
|
||||||
|
const detail = fixture();
|
||||||
|
for (const requested_by of [undefined, null, 'alice']) {
|
||||||
|
expect(isProvisioningDetail({ ...detail, deployment: { ...detail.deployment, requested_by } }, detail.deployment.id)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(isProvisioningDetail({ ...detail, deployment: { ...detail.deployment, requested_by: 42 } }, detail.deployment.id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('retains list filters without accepting an external or unrelated return destination', () => {
|
it('retains list filters without accepting an external or unrelated return destination', () => {
|
||||||
expect(provisioningReturn('/provisioning?view=history&action=restart&server=Lobby+1&limit=50&redirect=https://evil.test').href)
|
expect(provisioningReturn('/provisioning?view=history&action=restart&server=Lobby+1&limit=50&redirect=https://evil.test').href)
|
||||||
.toBe('/provisioning?view=history&action=restart&server=Lobby+1&limit=50');
|
.toBe('/provisioning?view=history&action=restart&server=Lobby+1&limit=50');
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
import { prepareNativeHost } from '../helpers/native-host.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||||
|
afterEach(async () => { await fixture.cleanup(); });
|
||||||
|
|
||||||
|
it('persists the manual requester while preserving source filtering and historical records', async () => {
|
||||||
|
const { withRequestUsername } = await import('../../src/lib/server/auth/request-context.js');
|
||||||
|
const { beginDeployment, getDeployment, listDeployments } = await import('../../src/lib/server/provisioning/history.js');
|
||||||
|
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const legacy = beginDeployment('legacy', 'restart');
|
||||||
|
const [manual, automatic] = await withRequestUsername('alice', async () => [
|
||||||
|
beginDeployment('manual', 'restart'), beginDeployment('automatic', 'restart', 'watchdog:incident')
|
||||||
|
]);
|
||||||
|
closeDb();
|
||||||
|
expect(getDeployment(manual)?.deployment).toMatchObject({ triggered_by: 'ui', requested_by: 'alice' });
|
||||||
|
expect(getDeployment(automatic)?.deployment).toMatchObject({ triggered_by: 'watchdog:incident', requested_by: null });
|
||||||
|
expect(getDeployment(legacy)?.deployment).toMatchObject({ triggered_by: 'ui', requested_by: null });
|
||||||
|
expect(listDeployments({ source: 'manual' }).map(row => row.id)).toEqual(expect.arrayContaining([manual, legacy]));
|
||||||
|
expect(listDeployments({ source: 'manual' }).map(row => row.id)).not.toContain(automatic);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates overlapping users and stops attributing deferred work after the request returns', async () => {
|
||||||
|
const { withRequestUsername, currentRequestUsername } = await import('../../src/lib/server/auth/request-context.js');
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||||
|
let deferred!: Promise<string | null>;
|
||||||
|
const first = withRequestUsername('alice', async () => {
|
||||||
|
deferred = gate.then(() => currentRequestUsername());
|
||||||
|
await Promise.resolve();
|
||||||
|
return currentRequestUsername();
|
||||||
|
});
|
||||||
|
const second = withRequestUsername('bob', async () => {
|
||||||
|
await first;
|
||||||
|
return currentRequestUsername();
|
||||||
|
});
|
||||||
|
expect(await Promise.all([first, second])).toEqual(['alice', 'bob']);
|
||||||
|
expect(currentRequestUsername()).toBeNull();
|
||||||
|
release();
|
||||||
|
expect(await deferred).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records the operator adopting watchdog work but retains the first requester on repeated manual requests', async () => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { beginDeployment, getDeployment } = await import('../../src/lib/server/provisioning/history.js');
|
||||||
|
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
|
||||||
|
const { withRequestUsername } = await import('../../src/lib/server/auth/request-context.js');
|
||||||
|
db().prepare("INSERT INTO servers(name) VALUES('server')").run();
|
||||||
|
await prepareNativeHost(['server']);
|
||||||
|
const id = beginDeployment('server', 'restart', 'watchdog:incident');
|
||||||
|
db().prepare("UPDATE server_deployments SET state='waiting' WHERE id=?").run(id);
|
||||||
|
db().prepare(`INSERT INTO watchdog_incidents(id,server,kind,state,summary,details,first_seen_at,last_seen_at)
|
||||||
|
VALUES('incident','server','health','recovering','Unhealthy','{}',?,?)`).run(new Date().toISOString(), new Date().toISOString());
|
||||||
|
db().prepare("INSERT INTO provisioning_sources(operation_id,kind,source_id,server) VALUES(?,'watchdog','incident','server')").run(id);
|
||||||
|
expect(await withRequestUsername('alice', async () => requestLifecycle('server', 'restart'))).toMatchObject({ ok: true, deploymentId: id });
|
||||||
|
expect(await withRequestUsername('bob', async () => requestLifecycle('server', 'restart'))).toMatchObject({ ok: true, deploymentId: id });
|
||||||
|
expect(getDeployment(id)?.deployment).toMatchObject({ requested_by: 'alice', triggered_by: 'ui' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the requester column to existing databases without inventing historical usernames', async () => {
|
||||||
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { beginDeployment, getDeployment } = await import('../../src/lib/server/provisioning/history.js');
|
||||||
|
const id = beginDeployment('legacy', 'restart');
|
||||||
|
db().exec('ALTER TABLE server_deployments DROP COLUMN requested_by');
|
||||||
|
closeDb();
|
||||||
|
expect(getDeployment(id)?.deployment).toMatchObject({ requested_by: null, triggered_by: 'ui' });
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user