Add Super Viewer read-only administrator role
CI / checks (push) Successful in 5m40s

This commit is contained in:
2026-10-11 23:30:40 -04:00
parent 253d47300c
commit 4ea291d305
48 changed files with 1684 additions and 933 deletions
+18 -5
View File
@@ -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';
}
+5 -1
View File
@@ -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 },
+2 -2
View File
@@ -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(
+29
View File
@@ -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;
`);
}
+7 -1
View File
@@ -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
}
];