Add native host management and role-based operator authentication
Introduce an outbound Python host agent, Debian installer, durable remote operations, immutable game-file libraries, host-aware deployments and ports, and occupancy-safe campaigns and draining. Native hosts do not require Komodo. Add administrator, operator and viewer accounts, protected management routes, secure sessions, password recovery, audit history and separate telemetry tokens. Document first-admin setup, reporter migration and live-host acceptance checks. Validation: 1,343 TypeScript tests and 39 agent tests passed, along with type checking, the production build, compiled SSR/API smoke tests and administrator recovery. Real Debian/Docker T5/T6 acceptance remains unrun.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { UserRole } from '../../src/lib/auth.js';
|
||||
import { roleAllows, routePermission, type RoutePermission } from '../../src/lib/server/auth/access.js';
|
||||
|
||||
const roles: UserRole[] = ['viewer', 'operator', 'admin'];
|
||||
const allows = (role: UserRole, route: string | null, method = 'GET') => roleAllows(role, routePermission(route, method));
|
||||
|
||||
describe('management route permissions', () => {
|
||||
it.each([
|
||||
'/', '/api/fleet', '/api/servers', '/servers/[name]', '/api/servers/[name]/scripts',
|
||||
'/api/metrics', '/metrics', '/api/matches', '/matches/[id]', '/api/leaderboard',
|
||||
'/leaderboard/players/[game]/[guid]', '/api/campaigns/[id]', '/campaigns/[id]',
|
||||
'/api/provisioning/[id]', '/provisioning/[id]', '/api/provisioning/[id]/events',
|
||||
'/api/betas/[id]', '/api/scripts/[id]', '/api/configs', '/api/plugins', '/api/hosts/[id]',
|
||||
'/api/game-files/[id]', '/api/maintenance', '/api/events/servers', '/api/events/pipeline'
|
||||
])('lets viewers read the safe summary route %s', route => {
|
||||
expect(routePermission(route, 'GET')).toBe('viewer');
|
||||
expect(allows('viewer', route)).toBe(true);
|
||||
expect(allows('viewer', route, 'HEAD')).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/api/servers', 'POST'], ['/api/servers/[name]', 'DELETE'], ['/api/provisioning', 'POST'],
|
||||
['/api/provisioning/[id]/cancel', 'POST'], ['/api/provisioning/[id]/resume', 'POST'],
|
||||
['/api/deployment-collections', 'POST'], ['/api/deployment-collections/preview', 'POST'],
|
||||
['/api/blueprints', 'POST'], ['/api/blueprints/[id]', 'PUT'], ['/api/blueprints/[id]', 'DELETE'],
|
||||
['/api/betas', 'POST'], ['/api/betas/[id]/promote', 'POST'], ['/api/betas/[id]/rollback', 'POST'],
|
||||
['/api/campaigns/[id]/rollback', 'POST'], ['/api/deployments/[id]/rollback', 'POST'],
|
||||
['/api/scripts', 'POST'], ['/api/scripts/[id]/versions', 'POST'], ['/api/scripts/[id]/versions/[versionId]', 'DELETE'],
|
||||
['/api/configs', 'POST'], ['/api/plugins/github/import', 'POST'],
|
||||
['/api/maintenance/[id]', 'POST'], ['/api/watchdog/[id]', 'POST'], ['/api/servers/[name]/probe-rcon', 'POST']
|
||||
])('requires an operator for %s %s', (route, method) => {
|
||||
expect(routePermission(route, method)).toBe('operator');
|
||||
expect(allows('viewer', route, method)).toBe(false);
|
||||
expect(allows('operator', route, method)).toBe(true);
|
||||
expect(allows('admin', route, method)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['/api/users', '/api/users/[id]', '/api/users/[id]/password', '/settings', '/settings/users', '/server-keys', '/keys', '/api/server-keys'])('reserves all methods on %s for administrators', route => {
|
||||
for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
expect(routePermission(route, method)).toBe('admin');
|
||||
expect(allows('viewer', route, method)).toBe(false);
|
||||
expect(allows('operator', route, method)).toBe(false);
|
||||
expect(allows('admin', route, method)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/api/hosts', 'POST'], ['/api/hosts/[id]', 'PATCH'], ['/api/hosts/[id]/enrollment', 'POST'],
|
||||
['/api/hosts/[id]/credentials', 'DELETE'], ['/api/hosts/[id]/credentials', 'POST'],
|
||||
['/api/hosts/[id]/operations', 'POST'], ['/api/settings', 'PUT'], ['/api/game-files', 'POST'],
|
||||
['/api/game-files/[id]/download', 'POST'], ['/api/maintenance', 'POST'], ['/api/watchdog', 'POST'], ['/api/plutonium', 'POST']
|
||||
])('protects host, game-file, and global policy mutations at %s', (route, method) => {
|
||||
expect(routePermission(route, method)).toBe('admin');
|
||||
expect(allows('operator', route, method)).toBe(false);
|
||||
expect(allows('admin', route, method)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/configs/[id]', '/api/configs/[id]', '/api/configs/inspect',
|
||||
'/api/campaigns/[id]/file/[fileId]', '/api/deployments/[id]/file/[fileId]',
|
||||
'/api/servers/[name]/logs/[file]', '/api/servers/[name]/telemetry-requests'
|
||||
])('prevents viewer access to secret-bearing raw content at %s', route => {
|
||||
for (const method of ['GET', 'HEAD']) {
|
||||
expect(routePermission(route, method)).toBe('operator');
|
||||
expect(allows('viewer', route, method)).toBe(false);
|
||||
expect(allows('operator', route, method)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication and machine boundaries', () => {
|
||||
it('makes only the intended login/bootstrap methods public', () => {
|
||||
for (const route of ['/login', '/setup']) {
|
||||
expect(routePermission(route, 'GET')).toBe('public');
|
||||
expect(routePermission(route, 'HEAD')).toBe('public');
|
||||
expect(routePermission(route, 'POST')).not.toBe('public');
|
||||
}
|
||||
for (const route of ['/api/auth/login', '/api/auth/setup']) {
|
||||
expect(routePermission(route, 'POST')).toBe('public');
|
||||
expect(routePermission(route, 'GET')).not.toBe('public');
|
||||
expect(routePermission(route, 'DELETE')).not.toBe('public');
|
||||
}
|
||||
expect(routePermission('/api/auth/unknown', 'POST')).toBe('admin');
|
||||
});
|
||||
it.each(['/account', '/api/auth/me', '/api/auth/password', '/api/auth/logout'])('permits every authenticated role to manage their own account at %s', route => {
|
||||
expect(routePermission(route, 'GET')).toBe('authenticated');
|
||||
expect(routePermission(route, 'POST')).toBe('authenticated');
|
||||
for (const role of roles) expect(allows(role, route, 'POST')).toBe(true);
|
||||
});
|
||||
it.each([
|
||||
['/api/agent/enroll', 'POST'], ['/api/agent/heartbeat', 'POST'], ['/api/agent/operations', 'GET'],
|
||||
['/api/agent/operations/[id]', 'POST'], ['/api/agent/libraries/[id]/files/[...path]', 'GET'],
|
||||
['/api/agent/imports/[id]/files/[...path]', 'PUT'], ['/api/agent/imports/[id]/complete', 'POST'],
|
||||
['/api/agent/install.sh', 'GET'], ['/api/agent/release/[file]', 'GET'], ['/api/telemetry', 'POST']
|
||||
])('preserves endpoint-specific machine authentication for %s', (route, method) => {
|
||||
expect(routePermission(route, method)).toBe('machine');
|
||||
for (const role of roles) expect(allows(role, route, method)).toBe(false);
|
||||
});
|
||||
it('does not grant machine exemptions to similar prefixes or unsupported telemetry methods', () => {
|
||||
for (const route of ['/api/agents', '/api/agentish/operations', '/api/telemetry-extra', '/api/telemetry/anything']) {
|
||||
expect(routePermission(route, 'POST')).toBe('operator');
|
||||
}
|
||||
expect(routePermission('/api/telemetry', 'GET')).toBe('viewer');
|
||||
expect(routePermission('/api/telemetry', 'PUT')).toBe('operator');
|
||||
});
|
||||
it('protects unknown routes and does not infer public assets from URL suffixes', () => {
|
||||
for (const route of [null, '/not-found', '/unrecognized.json', '/anything.css', '/__data.json']) {
|
||||
expect(routePermission(route, 'GET')).toBe('viewer');
|
||||
expect(routePermission(route, 'POST')).toBe('operator');
|
||||
expect(allows('viewer', route, 'POST')).toBe(false);
|
||||
}
|
||||
});
|
||||
it.each(['/api/agent/../server-keys', '/api/%61gent/operations', '/api/agent/%2e%2e/settings', '/api/agent\\operations', '//api/agent/operations', '/api/agent/operations?bypass=1'])('fails closed if a caller supplies noncanonical route identity %s', route => {
|
||||
expect(routePermission(route, 'GET')).toBe('admin');
|
||||
expect(routePermission(route, 'POST')).toBe('admin');
|
||||
});
|
||||
it('rejects invalid roles even when a permission would normally allow all users', () => {
|
||||
for (const permission of ['public', 'authenticated', 'viewer', 'operator', 'admin', 'machine'] as RoutePermission[]) {
|
||||
expect(roleAllows('unexpected' as UserRole, permission)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user