Show provisioning requesters and reserve Maxis for system actions

This commit is contained in:
2026-10-08 16:19:49 -04:00
parent 2b155ded8f
commit d8fb7e1d66
10 changed files with 77 additions and 15 deletions
+3
View File
@@ -1,5 +1,8 @@
export type UserRole = 'admin' | 'operator' | 'viewer';
/** Reserved identity for work requested by the controller. */
export const systemUsername = 'Maxis';
export interface AuthUser {
id: string;
username: string;
+1 -1
View File
@@ -29,7 +29,7 @@ export function provisioningMatchesSource(row: Pick<ProvisioningListRow, 'source
export type ProvisioningListRow = Pick<ProvisioningDeployment,
'id' | 'server' | 'server_deleted_at' | 'action' | 'state' | 'stage' | 'started_at' |
'finished_at' | 'triggered_by' | 'error' | 'warnings' | 'server_exists'> & {
'finished_at' | 'triggered_by' | 'requested_by' | 'error' | 'warnings' | 'server_exists'> & {
canCancelRemoval: number; canCancel?: number; sources?: ProvisioningSource[];
phase?: string | null; phaseStartedAt?: string | null; waitCode?: string | null;
};
+4 -2
View File
@@ -2,7 +2,7 @@ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypt
import { db } from '../db/index.js';
import { ensureAuthSchema } from './schema.js';
import { hashPassword, verifyPassword } from './passwords.js';
import type { AuthUser, UserRole } from '../../auth.js';
import { systemUsername, type AuthUser, type UserRole } from '../../auth.js';
export const SESSION_COOKIE = '__Host-ec_session';
export const SESSION_LIFETIME_MS = 12 * 60 * 60_000;
@@ -25,7 +25,9 @@ export function listUsers(): AuthUser[] { return (database().prepare('SELECT * F
export function setupRequired(): boolean { return !(database().prepare('SELECT 1 FROM auth_users LIMIT 1').get()); }
function username(value: unknown): string {
if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{2,63}$/.test(value)) throw new AuthError('Usernames need 3–64 letters, numbers, dots, underscores or hyphens.');
return value.toLowerCase();
const normalized = value.toLowerCase();
if (normalized === systemUsername.toLowerCase()) throw new AuthError(`${systemUsername} is reserved for system actions. Choose another username.`);
return normalized;
}
function displayName(value: unknown, fallback: string): string {
const name = value === undefined ? fallback : value;
+1 -1
View File
@@ -303,7 +303,7 @@ const reviewDeployment = `d.state IN ('failed', 'interrupted', 'warning') AND NO
SELECT 1 FROM retention_controls c WHERE c.category='provisioning' AND c.record_id=d.id
AND c.resolved_at >= COALESCE(d.finished_at,d.started_at))`;
const deploymentListColumns = `d.id, d.server, d.server_deleted_at, d.action, d.state, d.stage,
d.started_at, d.finished_at, d.triggered_by, d.error, d.warnings,
d.started_at, d.finished_at, d.triggered_by, d.requested_by, d.error, d.warnings,
d.phase_key AS phase, d.phase_started_at AS phaseStartedAt,
CASE WHEN json_valid(d.observation_json) THEN json_extract(d.observation_json,'$.blocker.code') END AS waitCode,
(d.server_deleted_at IS NULL AND EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server AND s.destroyed_at IS NULL)) AS server_exists,
+6 -1
View File
@@ -1,5 +1,10 @@
import { provisioningPhases, type ProvisioningDetail, type ProvisioningEvent, type ProvisioningEventPage, type ProvisioningPhase } from '$lib/provisioning-detail.js';
import { provisioningPhases, type ProvisioningDeployment, type ProvisioningDetail, type ProvisioningEvent, type ProvisioningEventPage, type ProvisioningPhase } from '$lib/provisioning-detail.js';
import { actionCompleted, actionLabels, provisioningActions, type ProvisioningAction } from '$lib/provisioning.js';
import { systemUsername } from '$lib/auth.js';
export function provisioningRequester(operation: Pick<ProvisioningDeployment, 'requested_by' | 'triggered_by'>): string {
return operation.requested_by ?? (operation.triggered_by === 'ui' ? 'Not recorded' : systemUsername);
}
export const activeProvisioningStates = ['queued', 'waiting', 'running', 'awaiting-health'];
export type ProgressState = 'done' | 'current' | 'waiting' | 'failed' | 'pending' | 'cancelled' | 'unknown';
+6 -4
View File
@@ -22,7 +22,7 @@
import { contextHref } from '$lib/navigation-context.js';
import { confirmAction } from '$ui/confirm.js';
import { serverTitle } from '$ui/server-labels.js';
import { elapsedTime } from '$ui/provisioning-presentation.js';
import { elapsedTime, provisioningRequester } from '$ui/provisioning-presentation.js';
import { provisioningQueueStatus, provisioningWarnings } from '$ui/provisioning-queue.js';
let lifecycleOpen = $state(false);
@@ -210,7 +210,7 @@
{#snippet operationTable(rows: Deployment[])}
<div class="table-scroll"><table>
<thead><tr><th scope="col">Server</th><th scope="col">Operation</th><th scope="col">Status</th><th scope="col">Source</th><th scope="col">Started</th><th scope="col" class="align-right">Details</th></tr></thead>
<thead><tr><th scope="col">Server</th><th scope="col">Operation</th><th scope="col">Status</th><th scope="col">Source</th><th scope="col">Requester</th><th scope="col">Started</th><th scope="col" class="align-right">Details</th></tr></thead>
<tbody>{#each rows as row (row.id)}
{@const status = provisioningQueueStatus(row)}
{@const primarySource = row.sources?.[0]}
@@ -221,14 +221,15 @@
<td><a href={operationHref}>{actionLabels[row.action]}</a></td>
<td><Badge variant={status.tone} size="md">{status.label}</Badge>{#if ['queued','waiting'].includes(status.group) && row.phaseStartedAt && now - Date.parse(row.phaseStartedAt) >= 600000}<small>In this stage {elapsedTime(row.phaseStartedAt, now)}</small>{/if}</td>
<td class="source-cell">{#if primarySource?.href}<a href={contextHref(primarySource.href, $page.url)}>{provisioningSourceLabel(row)}</a>{:else}<span>{provisioningSourceLabel(row)}</span>{/if}{#if (row.sources?.length ?? 0) > 1}<small>+{row.sources!.length - 1} more</small>{/if}</td>
<td class="requester-cell">{provisioningRequester(row)}</td>
<td><RelativeTime value={row.started_at} /></td>
<td class="align-right"><button class="text-button" aria-expanded={isExpanded} aria-controls={`operation-${row.id}`} aria-label={`${isExpanded ? 'Hide' : 'Show'} ${actionLabels[row.action].toLowerCase()} details for ${serverTitle(row.server)}`} onclick={() => expanded = { ...expanded, [row.id]: !isExpanded }}>{isExpanded ? 'Hide' : 'Details'} <span aria-hidden="true">{isExpanded ? '−' : '+'}</span></button></td>
</tr>
<tr hidden={!isExpanded} id={`operation-${row.id}`} class="detail-row"><td colspan="6">
<tr hidden={!isExpanded} id={`operation-${row.id}`} class="detail-row"><td colspan="7">
<div class="operation-details">
<p>{row.stage}</p>{#if row.error}<p class="error">{row.error}</p>{/if}
{#each provisioningWarnings(row.warnings) as warning}<p class="warning">{warning}</p>{/each}
<dl><div><dt>Started</dt><dd>{fmt(row.started_at)}</dd></div>{#if row.finished_at}<div><dt>Finished</dt><dd>{fmt(row.finished_at)}</dd></div>{/if}<div><dt>{row.finished_at ? 'Duration' : 'Elapsed'}</dt><dd>{elapsedTime(row.started_at, row.finished_at ? Date.parse(row.finished_at) : now)}</dd></div><div><dt>Operation ID</dt><dd class="mono">{row.id}</dd></div></dl>
<dl><div><dt>Requester</dt><dd>{provisioningRequester(row)}</dd></div><div><dt>Started</dt><dd>{fmt(row.started_at)}</dd></div>{#if row.finished_at}<div><dt>Finished</dt><dd>{fmt(row.finished_at)}</dd></div>{/if}<div><dt>{row.finished_at ? 'Duration' : 'Elapsed'}</dt><dd>{elapsedTime(row.started_at, row.finished_at ? Date.parse(row.finished_at) : now)}</dd></div><div><dt>Operation ID</dt><dd class="mono">{row.id}</dd></div></dl>
{#if row.sources?.length}<div class="detail-links">{#each row.sources as item}{#if item.href}<a href={contextHref(item.href, $page.url)}>{item.label}</a>{:else}<span class="muted">{item.label}</span>{/if}{/each}</div>{/if}
<div class="detail-links"><a href={operationHref}>Open operation</a>{#if row.server_exists && !row.server_deleted_at}<a href={contextHref(`/servers/${encodeURIComponent(row.server)}?tab=diagnostics`, $page.url)}>Diagnostics</a><a href={contextHref(`/servers/${encodeURIComponent(row.server)}?tab=logs`, $page.url)}>Open logs</a>{/if}{#if row.canCancel && !row.server_deleted_at}<Button variant="danger" size="sm" disabled={cancelling} onclick={() => cancelRequests([row])}>Cancel operation</Button>{/if}</div>
</div>
@@ -249,6 +250,7 @@
th { padding:10px 12px; color:var(--text-tertiary); font-size:var(--text-xs); font-weight:var(--weight-medium); border-bottom:1px solid var(--border-strong); }
td { padding:16px 12px; border-bottom:1px solid var(--border-subtle); vertical-align:middle; }tbody tr:hover { background:var(--surface); }
a { color:var(--accent-primary-hover); }.server-cell { min-width:160px; font-weight:var(--weight-semibold); }.source-cell { max-width:240px; overflow-wrap:anywhere; }
.requester-cell { max-width:180px; overflow-wrap:anywhere; }
small { display:block; margin-top:6px; color:var(--text-tertiary); font-size:var(--text-xs); }.align-right { text-align:right; }
.text-button { border:0; background:transparent; color:var(--text-secondary); font:inherit; padding:6px 0; cursor:pointer; white-space:nowrap; }
.detail-row { background:var(--surface); }.operation-details { display:grid; gap:16px; line-height:1.6; overflow-wrap:anywhere; }
+2 -2
View File
@@ -9,7 +9,7 @@
import { serverTitle } from '$ui/server-labels.js';
import { createProvisioningRefresh, type ProvisioningRefreshState } from '$ui/provisioning-refresh.js';
import { confirmAction } from '$ui/confirm.js';
import { elapsedTime, isProvisioningDetail, provisioningOutcome, provisioningReturn } from '$ui/provisioning-presentation.js';
import { elapsedTime, isProvisioningDetail, provisioningOutcome, provisioningRequester, provisioningReturn } from '$ui/provisioning-presentation.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
@@ -171,7 +171,7 @@
{/if}
<Card as="section" padding="md" ariaLabel="Operation details">
<div class="context-section"><h2>Operation details</h2><dl class="metadata">
<div><dt>Requested by</dt><dd>{deployment.requested_by ?? deployment.triggered_by}</dd></div>
<div><dt>Requester</dt><dd>{provisioningRequester(deployment)}</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>Finished</dt><dd><RelativeTime value={deployment.finished_at} missing={outcome.active ? 'In progress' : 'Not recorded'} /></dd></div>
+24
View File
@@ -54,6 +54,16 @@ function gate<T>() {
}
describe('initial administrator setup', () => {
it('reserves Maxis for system actions regardless of username case', async () => {
for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) {
await expect(auth.bootstrapAdmin({ username, password, token: setupToken }, '192.0.2.1'))
.rejects.toMatchObject({ status: 400, message: 'Maxis is reserved for system actions. Choose another username.' });
}
expect(auth.setupRequired()).toBe(true);
expect(auth.listUsers()).toEqual([]);
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 0 });
});
it('requires an owner token and never creates an account from missing or wrong credentials', async () => {
const input = { username: 'Admin', password, token: setupToken };
vi.stubEnv('EC_SETUP_TOKEN', undefined);
@@ -136,6 +146,20 @@ describe('password storage and verification', () => {
});
describe('administrator user management', () => {
it('rejects the reserved system username for every role while allowing distinct usernames', async () => {
const admin = seedUser();
for (const role of ['admin', 'operator', 'viewer'] as const) {
for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) {
await expect(auth.createUser(admin.id, { username, role, password }))
.rejects.toMatchObject({ status: 400, message: 'Maxis is reserved for system actions. Choose another username.' });
}
}
expect(auth.listUsers()).toHaveLength(1);
expect(auth.listAuthAudit()).toEqual([]);
await expect(auth.createUser(admin.id, { username: 'maxis-operator', role: 'operator', password }))
.resolves.toMatchObject({ username: 'maxis-operator' });
});
it('creates each supported role with a forced initial password change and rejects case-insensitive duplicate names', async () => {
const admin = seedUser();
for (const role of ['admin', 'operator', 'viewer'] as const) {
+14 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { ProvisioningDetail, ProvisioningProgress } from '../../src/lib/provisioning-detail.js';
import { provisioningActions } from '../../src/lib/provisioning.js';
import { isProvisioningDetail, isProvisioningEventPage, provisioningGroups, provisioningOutcome, provisioningReturn } from '../../src/lib/ui/provisioning-presentation.js';
import { isProvisioningDetail, isProvisioningEventPage, provisioningGroups, provisioningOutcome, provisioningRequester, provisioningReturn } from '../../src/lib/ui/provisioning-presentation.js';
const at = '2026-09-28T12:00:00Z';
function progress(patch: Partial<ProvisioningProgress> = {}): ProvisioningProgress {
@@ -19,6 +19,19 @@ function fixture(): ProvisioningDetail {
}
describe('provisioning outcome and evidence presentation', () => {
it('shows recorded usernames and preserves unknown manual requesters', () => {
expect(provisioningRequester({ triggered_by: 'ui', requested_by: 'alice' })).toBe('alice');
expect(provisioningRequester({ triggered_by: 'rebalance:run', requested_by: 'bob' })).toBe('bob');
expect(provisioningRequester({ triggered_by: 'ui', requested_by: null })).toBe('Not recorded');
expect(provisioningRequester({ triggered_by: 'ui' })).toBe('Not recorded');
});
it.each(['watchdog:incident', 'maintenance:job', 'autoscaling:policy', 'campaign:run', 'beta:75', 'rebalance:run', 'host-drain'])(
'shows Maxis for system work from %s', triggered_by => {
expect(provisioningRequester({ triggered_by, requested_by: null })).toBe('Maxis');
expect(provisioningRequester({ triggered_by })).toBe('Maxis');
});
it('distinguishes a failed creation retry from readiness verification and its linked recovery', () => {
const detail = fixture();
detail.deployment.state = 'failed';
+16 -3
View File
@@ -9,17 +9,30 @@ 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 { db, 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')
const [manual, automatic, rebalance] = await withRequestUsername('alice', async () => [
beginDeployment('manual', 'restart'), beginDeployment('automatic', 'restart', 'watchdog:incident'),
beginDeployment('rebalance', 'restart', 'rebalance:run')
]);
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(rebalance)?.deployment).toMatchObject({ triggered_by: 'rebalance:run', requested_by: 'alice' });
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);
const { GET } = await import('../../src/routes/api/provisioning/+server.js');
for (const view of ['active', 'history']) {
if (view === 'history') db().prepare("UPDATE server_deployments SET state='done'").run();
const result = await GET({ url: new URL(`http://localhost/api/provisioning?view=${view}`) }).json();
expect(result.deployments).toEqual(expect.arrayContaining([
expect.objectContaining({ id: manual, requested_by: 'alice' }),
expect.objectContaining({ id: automatic, requested_by: null }),
expect.objectContaining({ id: rebalance, requested_by: 'alice' }),
expect.objectContaining({ id: legacy, requested_by: null })
]));
}
});
it('isolates overlapping users and stops attributing deferred work after the request returns', async () => {