diff --git a/docs/guides/authentication.md b/docs/guides/authentication.md index f413f7e..ad2517c 100644 --- a/docs/guides/authentication.md +++ b/docs/guides/authentication.md @@ -31,16 +31,23 @@ guessable token. An unset setup token holds setup closed. ## Roles -| Capability | Viewer | Operator | Administrator | -| --------------------------------------------------------------------------------------------- | -------------- | ----------- | ------------- | -| Fleet, metrics, matches and operation summaries | Yes | Yes | Yes | -| Raw configs, file diffs, logs, RCON and telemetry diagnostics | No | Yes | Yes | -| Deploy/start/stop/restart/rebuild/remove servers | No | Yes | Yes | -| Scripts, plugins, configs, campaigns, betas and collections | Read summaries | Manage | Manage | -| Host setup, deletion, enrollment, credentials, runtime preparation and game-file upload/cache | Read status | Read status | Manage | -| Server keys and global settings/maintenance policies | No | No | Manage | -| Create users, change roles, disable accounts and reset passwords | No | No | Yes | -| Own password and sign out | Yes | Yes | Yes | +| Capability | Viewer | Super Viewer | Operator | Administrator | +| --------------------------------------------------------------------------------------------- | -------------- | ------------ | ----------- | ------------- | +| Fleet, metrics, matches and operation summaries | Yes | Yes | Yes | Yes | +| Raw configs, file diffs, logs, saved RCON and telemetry diagnostics | No | Read | Read | Read | +| Deploy/start/stop/restart/rebuild/remove servers and run diagnostic probes | No | No | Yes | Yes | +| Scripts, plugins, configs, campaigns, betas and collections | Read summaries | Read | Manage | Manage | +| Host setup, deletion, enrollment, credentials, runtime preparation and game-file upload/cache | Read status | Read status | Read status | Manage | +| Server keys and global settings/maintenance policies | No | Read | No | Manage | +| Users and account audit activity | No | Read | No | Manage | +| Create users, change roles, disable accounts and reset passwords | No | No | No | Yes | +| Own password and sign out | Yes | Yes | Yes | Yes | + +Super Viewer is a read-only administrator. It includes the Admin navigation and +access to sensitive configuration and diagnostic content, but cannot change fleet +state, settings, keys, or other accounts. Cleanup previews remain available because +they do not save settings or run cleanup. Assign it only to people trusted to read +administrative information. Existing accounts and sessions are preserved on upgrade. Operators can launch using available game-pool keys already saved by an administrator. They cannot save keys indirectly through a launch request. An operator's content @@ -50,9 +57,9 @@ people trusted to operate the game fleet. Drift previews (`/api/drift`) and GitHub release lookups (`/api/plugins/github/release`) require operator or administrator access, including `GET` and `HEAD` requests. These endpoints initiate fleet file scans or use GitHub -API quota; viewers cannot trigger them. +API quota; Viewers and Super Viewers cannot trigger them. -RCON failure replies are available only to operators and administrators in the +Saved RCON failure replies are available to Super Viewers, operators and administrators in the server's Diagnostics tab. Player addresses are redacted before diagnostics are saved; successful checks clear the saved reply. Fleet snapshots and live status streams contain no reply snippets. Upgrades discard older saved RCON snippets. @@ -79,7 +86,7 @@ revocation every 15 seconds and then close. Login, initial setup and password verification are rate-limited with persistent 15-minute windows. The database stores audit entries for login results, account changes and successful management requests without recording passwords, tokens -or request bodies. Administrators can review recent entries in Users. +or request bodies. Administrators and Super Viewers can review recent entries in Users. The password service runs at most two scrypt operations at once and queues up to 16 more for up to two seconds. If that queue fills or the wait expires, the diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 44c5d3d..781868d 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,4 @@ -export type UserRole = 'admin' | 'operator' | 'viewer'; +export type UserRole = 'admin' | 'super_viewer' | 'operator' | 'viewer'; /** Reserved identity for work requested by the controller. */ export const systemUsername = 'Maxis'; @@ -17,6 +17,19 @@ export interface AuthUser { export const roleLabels: Record = { admin: 'Administrator', + super_viewer: 'Super Viewer', operator: 'Operator', viewer: 'Viewer' }; + +export function canOperate(role: UserRole | null | undefined): boolean { + return role === 'admin' || role === 'operator'; +} + +export function canReadAdmin(role: UserRole | null | undefined): boolean { + return role === 'admin' || role === 'super_viewer'; +} + +export function canReadSensitive(role: UserRole | null | undefined): boolean { + return canOperate(role) || role === 'super_viewer'; +} diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index 29fdc08..7702e7e 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -1,4 +1,4 @@ -import type { UserRole } from './auth.js'; +import { canReadAdmin, type UserRole } from './auth.js'; export interface NavigationItem { label: string; @@ -99,7 +99,7 @@ export const navigationGroups: NavigationGroup[] = [ ]; export function visibleNavigation(role: UserRole): NavigationGroup[] { - return navigationGroups.filter((group) => !group.admin || role === 'admin'); + return navigationGroups.filter((group) => !group.admin || canReadAdmin(role)); } export function navigationItemActive(item: NavigationItem, path: string): boolean { diff --git a/src/lib/server/auth/access.ts b/src/lib/server/auth/access.ts index 727eca4..fb0e4b1 100644 --- a/src/lib/server/auth/access.ts +++ b/src/lib/server/auth/access.ts @@ -1,7 +1,14 @@ import type { UserRole } from '../../auth.js'; export type RoutePermission = - 'public' | 'machine' | 'authenticated' | 'viewer' | 'operator' | 'admin'; + | 'public' + | 'machine' + | 'authenticated' + | 'viewer' + | 'operator-read' + | 'admin-read' + | 'operator' + | 'admin'; const within = (route: string, base: string): boolean => route === base || route.startsWith(base + '/'); @@ -41,16 +48,19 @@ export function routePermission(routeId: string | null, method: string): RoutePe ) return 'authenticated'; + // This POST only calculates a preview; it never saves settings or runs cleanup. + if (routeId === '/api/settings/retention/preview' && method === 'POST') return 'admin-read'; + if (within(routeId, '/api/auth')) return 'admin'; + if ( within(routeId, '/api/users') || within(routeId, '/settings') || within(routeId, '/api/settings') || - within(routeId, '/api/auth') || within(routeId, '/api/server-keys') || within(routeId, '/server-keys') || within(routeId, '/keys') ) - return 'admin'; + return reading ? 'admin-read' : 'admin'; if (!reading && ['/api/hosts', '/api/game-files'].some((base) => within(routeId, base))) return 'admin'; @@ -76,17 +86,20 @@ export function routePermission(routeId: string | null, method: string): RoutePe routeId === '/api/servers/[name]/telemetry-requests' || routeId === '/api/servers/[name]/rcon-diagnostics' ) - return 'operator'; + return reading ? 'operator-read' : 'operator'; return reading ? 'viewer' : 'operator'; } /** A browser role never substitutes for a machine credential. */ export function roleAllows(role: UserRole, permission: RoutePermission): boolean { - if (!['viewer', 'operator', 'admin'].includes(role)) return false; + if (!['viewer', 'super_viewer', 'operator', 'admin'].includes(role)) return false; if (permission === 'machine') return false; if (permission === 'public' || permission === 'authenticated' || permission === 'viewer') return true; if (permission === 'operator') return role === 'operator' || role === 'admin'; + if (permission === 'operator-read') + return role === 'super_viewer' || role === 'operator' || role === 'admin'; + if (permission === 'admin-read') return role === 'super_viewer' || role === 'admin'; return permission === 'admin' && role === 'admin'; } diff --git a/src/lib/server/auth/guard.ts b/src/lib/server/auth/guard.ts index 31c0e7c..81fa984 100644 --- a/src/lib/server/auth/guard.ts +++ b/src/lib/server/auth/guard.ts @@ -165,7 +165,11 @@ export const authenticationHandle: Handle = async ({ event, resolve }) => { const message = permission === 'admin' ? 'Administrator access is required.' - : 'Operator access is required for this action.'; + : permission === 'admin-read' + ? 'Administrator or Super Viewer access is required.' + : permission === 'operator-read' + ? 'Operator, Administrator or Super Viewer access is required.' + : 'Operator access is required for this action.'; if (api) return json( { error: message }, diff --git a/src/lib/server/auth/index.ts b/src/lib/server/auth/index.ts index 1167d51..49cc42e 100644 --- a/src/lib/server/auth/index.ts +++ b/src/lib/server/auth/index.ts @@ -81,8 +81,8 @@ function displayName(value: unknown, fallback: string): string { return name.trim(); } function role(value: unknown): UserRole { - if (!['admin', 'operator', 'viewer'].includes(value as string)) - throw new AuthError('Choose administrator, operator or viewer.'); + if (!['admin', 'super_viewer', 'operator', 'viewer'].includes(value as string)) + throw new AuthError('Choose administrator, super viewer, operator or viewer.'); return value as UserRole; } export function recordAudit( diff --git a/src/lib/server/auth/schema.ts b/src/lib/server/auth/schema.ts index 4ccd89a..518558b 100644 --- a/src/lib/server/auth/schema.ts +++ b/src/lib/server/auth/schema.ts @@ -23,3 +23,32 @@ export function migrateAuthSchema(handle: Database.Database): void { CREATE INDEX IF NOT EXISTS auth_audit_created_at ON auth_audit(created_at); `); } + +/** Run with foreign keys disabled by the migration runner before its transaction. */ +export function migrateSuperViewerRole(handle: Database.Database): void { + const schema = handle + .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='auth_users'") + .get() as { sql: string }; + if (schema.sql.includes("'super_viewer'")) return; + if (handle.pragma('foreign_keys', { simple: true })) + throw new Error('The auth role migration requires foreign keys to be disabled'); + + // Rebuild the parent table without renaming it first, so session references + // keep pointing to auth_users and existing sessions are not cascade-deleted. + handle.exec(` + CREATE TABLE auth_users_super_viewer ( + id TEXT PRIMARY KEY, username TEXT NOT NULL COLLATE NOCASE UNIQUE, + display_name TEXT NOT NULL, password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('admin','super_viewer','operator','viewer')), + disabled INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER + ); + INSERT INTO auth_users_super_viewer ( + id,username,display_name,password_hash,role,disabled,must_change_password, + created_at,updated_at,last_login_at + ) SELECT id,username,display_name,password_hash,role,disabled,must_change_password, + created_at,updated_at,last_login_at FROM auth_users; + DROP TABLE auth_users; + ALTER TABLE auth_users_super_viewer RENAME TO auth_users; + `); +} diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 52bb46a..2df5609 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -14,7 +14,7 @@ import { } from '../reports/revisions.js'; import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js'; import { migrateHostSchema } from '../hosts/schema.js'; -import { migrateAuthSchema } from '../auth/schema.js'; +import { migrateAuthSchema, migrateSuperViewerRole } from '../auth/schema.js'; import { migrateGameFileSchema } from '../game-files/schema.js'; import type { Migration } from './migration-runner.js'; @@ -91,6 +91,12 @@ export const migrations: readonly Migration[] = [ version: 16, name: 'metrics player history and report revisions', run: installMetricsPlayerRevisions + }, + { + version: 17, + name: 'super viewer authentication role', + mode: 'foreign-keys-off', + run: migrateSuperViewerRole } ]; diff --git a/src/lib/ui/command-palette.ts b/src/lib/ui/command-palette.ts index 7763dc8..4979804 100644 --- a/src/lib/ui/command-palette.ts +++ b/src/lib/ui/command-palette.ts @@ -1,4 +1,4 @@ -import type { UserRole } from '../auth.js'; +import { canOperate, type UserRole } from '../auth.js'; import { visibleNavigation } from '../navigation.js'; import { playerProfileHref } from '../leaderboard.js'; import { serverTitle } from './server-labels.js'; @@ -48,25 +48,24 @@ function matches(query: string, ...values: Array) { } export function palettePageResults(role: UserRole, query: string): PaletteItem[] { - const actions: PaletteItem[] = - role === 'viewer' - ? [] - : [ - { - key: 'action:create', - group: 'Actions', - label: 'Create server', - detail: 'Choose a map and host', - href: '/?action=create' - }, - { - key: 'action:manage', - group: 'Actions', - label: 'Manage servers', - detail: 'Server queues and lifecycle actions', - href: '/provisioning' - } - ]; + const actions: PaletteItem[] = !canOperate(role) + ? [] + : [ + { + key: 'action:create', + group: 'Actions', + label: 'Create server', + detail: 'Choose a map and host', + href: '/?action=create' + }, + { + key: 'action:manage', + group: 'Actions', + label: 'Manage servers', + detail: 'Server queues and lifecycle actions', + href: '/provisioning' + } + ]; if (role === 'admin') actions.push({ key: 'action:add-host', diff --git a/src/lib/ui/components/ArtifactDetail.svelte b/src/lib/ui/components/ArtifactDetail.svelte index 3d1a768..ec844b0 100644 --- a/src/lib/ui/components/ArtifactDetail.svelte +++ b/src/lib/ui/components/ArtifactDetail.svelte @@ -2,6 +2,7 @@ import { serverTitle } from '$ui/server-labels.js'; import RelativeTime from '$ui/components/RelativeTime.svelte'; import { page } from '$app/stores'; + import { canOperate } from '$lib/auth.js'; import { contextHref } from '$lib/navigation-context.js'; import { onMount } from 'svelte'; import { pollVisible } from '$ui/poll.js'; @@ -33,6 +34,7 @@ family: Family; } let { id, family }: Props = $props(); + const editable = $derived(canOperate($page.data?.user?.role)); const basePath = $derived(`/${family}s`); const crumbLabel = $derived(family.charAt(0).toUpperCase() + family.slice(1) + 's'); @@ -228,7 +230,7 @@ onMount(() => pollVisible(() => load({ seedForm: false }), 10000, false)); function openScopeModal(): void { - if (runningBetaId !== null) return; + if (!editable || runningBetaId !== null) return; seedScopeForm(); flash = null; scopeReviewOpen = true; @@ -240,7 +242,7 @@ scopeDialog?.close(); } function openUploadModal(): void { - if (runningBetaId !== null) return; + if (!editable || runningBetaId !== null) return; releaseVersion = ''; versionFile = null; versionNotes = ''; @@ -273,7 +275,7 @@ async function uploadVersion(e: Event): Promise { e.preventDefault(); - if (!versionFile || uploadingVersion) return; + if (!editable || !versionFile || uploadingVersion) return; uploadingVersion = true; flash = null; const form = new FormData(); @@ -304,7 +306,7 @@ async function saveScope(e: Event): Promise { e.preventDefault(); - if (updatingScope || !scopeImpact || scopePreviewLoading) return; + if (!editable || updatingScope || !scopeImpact || scopePreviewLoading) return; if (scopeType !== 'none' && !editedScopeValue) { flash = `Pick a ${scopeType}`; return; @@ -351,6 +353,7 @@ } async function deleteArtifact(): Promise { + if (!editable) return; if ( !(await confirmAction({ title: 'Delete ' + family + '?', @@ -428,13 +431,15 @@ variant="primary" href={contextHref(`/betas/${runningBetaId}`, $page.url)} >View active beta{:else if latestVersion.id !== a.current_version_id}{:else}Latest version is promoted{/if} + >{:else if latestVersion.id === a.current_version_id}Latest version is promoted{/if} {/if} @@ -450,24 +455,24 @@

Details

-
- - -
+ {#if editable}
+ + +
{/if}
@@ -584,7 +589,7 @@ Promoted {:else} - {#if runningBetaId === null} - + {#if editable} +

Danger zone

+ +
{/if} {#if flash}{/if} - (scopeReviewOpen = false)} - oncancel={(event) => { - if (updatingScope) event.preventDefault(); - }} - bind:this={scopeDialog} - onclick={(e) => onDialogClick(scopeDialog, e)} - > -
-
-

Edit scope

- -
- -
- (scopeMap = '')} - options={gameOptions} + ariaLabel="Scope type" + bind:value={scopeType} + options={scopeTypeOptions} disabled={updatingScope} /> - {/if} - {#if scopeType === 'map'} - (scopeMap = '')} + options={gameOptions} + disabled={updatingScope} + /> + {/if} + {#if scopeType === 'map'} + - - {#if family === 'plugin'} - + + { + if (uploadingVersion) event.preventDefault(); + }} + bind:this={uploadDialog} + onclick={(e) => onDialogClick(uploadDialog, e)} + > + +
+

Upload new version

+ +
+ - {/if} - - - -
- - -
- {#if flash}{/if} - -
+ {#if family === 'plugin'} + + + + {/if} + + + +
+ + +
+ {#if flash}{/if} + +
{/if} {/if} diff --git a/src/lib/ui/components/BetaTestProgress.svelte b/src/lib/ui/components/BetaTestProgress.svelte index c9b84ad..1c6dcc6 100644 --- a/src/lib/ui/components/BetaTestProgress.svelte +++ b/src/lib/ui/components/BetaTestProgress.svelte @@ -7,6 +7,7 @@ import { timeFormat } from '$ui/stores/time-zone.js'; import { tick } from 'svelte'; import { page } from '$app/stores'; + import { canOperate } from '$lib/auth.js'; import { contextHref } from '$lib/navigation-context.js'; import type { BetaDeploymentServer } from '$ui/betas.js'; import Badge from './primitives/Badge.svelte'; @@ -52,13 +53,14 @@ evidenceKind = $state(''), evidenceQuery = $state(''); let evidenceSection = $state(); + const editable = $derived(canOperate($page.data?.user?.role)); const hasDeletedServers = $derived( deletedParticipants || test.servers.some((server) => server.server_deleted_at) || deployments?.some((server) => server.server_deleted_at) ); const canSign = $derived( - !closed && !hasDeletedServers && ['active', 'passed'].includes(test.outcome) + editable && !closed && !hasDeletedServers && ['active', 'passed'].includes(test.outcome) ); const expired = $derived( test.outcome === 'inconclusive' || @@ -139,6 +141,7 @@ evidenceLimit = 25; }); export async function action(action: string, extra: Record = {}) { + if (!editable) return; if ( action === 'retry' && !(await confirmAction({ @@ -197,7 +200,7 @@ {#if !deployments}{/if} {#if !closed && !deployments} - {#if !hasDeletedServers}
+ {#if editable && !hasDeletedServers}
{#if test.outcome === 'paused' && !expired}