This commit is contained in:
@@ -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[]) {
|
||||
|
||||
Reference in New Issue
Block a user