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
+46 -7
View File
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest';
import { readdirSync } from 'node:fs';
import { relative, resolve } from 'node:path';
import type { UserRole } from '../../src/lib/auth.js';
import {
roleAllows,
@@ -6,11 +8,42 @@ import {
type RoutePermission
} from '../../src/lib/server/auth/access.js';
const roles: UserRole[] = ['viewer', 'operator', 'admin'];
const roles: UserRole[] = ['viewer', 'super_viewer', 'operator', 'admin'];
const allows = (role: UserRole, route: string | null, method = 'GET') =>
roleAllows(role, routePermission(route, method));
describe('management route permissions', () => {
it('denies Super Viewer writes across every registered management route', () => {
const routes = resolve('src/routes');
const entries = readdirSync(routes, { recursive: true, withFileTypes: true });
const accountRoutes = new Set([
'/api/auth/login',
'/api/auth/setup',
'/api/auth/logout',
'/api/auth/password',
'/api/auth/me',
'/account'
]);
for (const entry of entries) {
if (!/^\+(server|page)(\.server)?\.(ts|svelte)$/.test(entry.name)) continue;
const route = '/' + relative(routes, entry.parentPath).split('\\').join('/');
if (accountRoutes.has(route) || route.startsWith('/api/agent/')) continue;
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
const preview = route === '/api/settings/retention/preview' && method === 'POST';
expect(allows('super_viewer', route, method), `${method} ${route}`).toBe(preview);
}
}
});
it('allows cleanup previews without permitting retention changes or operational reads', () => {
expect(allows('super_viewer', '/api/settings/retention/preview', 'POST')).toBe(true);
expect(allows('operator', '/api/settings/retention/preview', 'POST')).toBe(false);
expect(allows('super_viewer', '/api/settings/retention', 'POST')).toBe(false);
for (const route of ['/api/drift', '/api/plugins/github/release'])
for (const method of ['GET', 'HEAD'])
expect(allows('super_viewer', route, method)).toBe(false);
});
it.each([
'/',
'/api/fleet',
@@ -77,6 +110,7 @@ describe('management route permissions', () => {
])('requires an operator for %s %s', (route, method) => {
expect(routePermission(route, method)).toBe('operator');
expect(allows('viewer', route, method)).toBe(false);
expect(allows('super_viewer', route, method)).toBe(false);
expect(allows('operator', route, method)).toBe(true);
expect(allows('admin', route, method)).toBe(true);
});
@@ -89,14 +123,14 @@ describe('management route permissions', () => {
'/settings/users',
'/api/settings',
'/api/settings/retention',
'/api/settings/retention/preview',
'/server-keys',
'/keys',
'/api/server-keys',
'/api/provisioning/[id]/override'
])('reserves all methods on %s for administrators', (route) => {
'/api/server-keys'
])('allows Super Viewers to read admin route %s without changing it', (route) => {
for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']) {
expect(routePermission(route, method)).toBe('admin');
const reading = method === 'GET' || method === 'HEAD';
expect(routePermission(route, method)).toBe(reading ? 'admin-read' : 'admin');
expect(allows('super_viewer', route, method)).toBe(reading);
expect(allows('viewer', route, method)).toBe(false);
expect(allows('operator', route, method)).toBe(false);
expect(allows('admin', route, method)).toBe(true);
@@ -104,6 +138,7 @@ describe('management route permissions', () => {
});
it.each([
['/api/provisioning/[id]/override', 'POST'],
['/api/hosts', 'POST'],
['/api/hosts/[id]', 'PATCH'],
['/api/hosts/[id]', 'DELETE'],
@@ -120,6 +155,7 @@ describe('management route permissions', () => {
['/api/plutonium', 'POST']
])('protects host, game-file, and global policy mutations at %s', (route, method) => {
expect(routePermission(route, method)).toBe('admin');
expect(allows('super_viewer', route, method)).toBe(false);
expect(allows('operator', route, method)).toBe(false);
expect(allows('admin', route, method)).toBe(true);
});
@@ -136,7 +172,8 @@ describe('management route permissions', () => {
'/api/servers/[name]/rcon-diagnostics'
])('prevents viewer access to secret-bearing raw content at %s', (route) => {
for (const method of ['GET', 'HEAD']) {
expect(routePermission(route, method)).toBe('operator');
expect(routePermission(route, method)).toBe('operator-read');
expect(allows('super_viewer', route, method)).toBe(true);
expect(allows('viewer', route, method)).toBe(false);
expect(allows('operator', route, method)).toBe(true);
}
@@ -243,6 +280,8 @@ describe('authentication and machine boundaries', () => {
'authenticated',
'viewer',
'operator',
'operator-read',
'admin-read',
'admin',
'machine'
] as RoutePermission[]) {