122 lines
5.3 KiB
TypeScript
122 lines
5.3 KiB
TypeScript
import { canModerate, canReadModeration, type UserRole } from '../../auth.js';
|
|
|
|
export type RoutePermission =
|
|
| 'public'
|
|
| 'machine'
|
|
| 'authenticated'
|
|
| 'viewer'
|
|
| 'operator-read'
|
|
| 'admin-read'
|
|
| 'moderation-read'
|
|
| 'moderation'
|
|
| 'operator'
|
|
| 'admin';
|
|
|
|
const within = (route: string, base: string): boolean =>
|
|
route === base || route.startsWith(base + '/');
|
|
const readMethod = (method: string): boolean => method === 'GET' || method === 'HEAD';
|
|
|
|
/** Classify SvelteKit's resolved route ID, never a user-supplied URL pathname.
|
|
* Route IDs preserve the same policy for page-data requests and encoded URLs.
|
|
* Machine endpoints enforce their own host/enrollment/telemetry credentials.
|
|
*/
|
|
export function routePermission(routeId: string | null, method: string): RoutePermission {
|
|
method = method.toUpperCase();
|
|
const reading = readMethod(method);
|
|
if (routeId === null) return reading ? 'viewer' : 'operator';
|
|
// These cannot occur in this application's resolved route IDs. Fail closed
|
|
// if a future caller accidentally supplies a raw or noncanonical pathname.
|
|
if (
|
|
!routeId.startsWith('/') ||
|
|
// eslint-disable-next-line no-control-regex -- Reject control characters in resolved route IDs.
|
|
/[%\\?#\x00-\x1f\x7f]/.test(routeId) ||
|
|
(routeId !== '/' &&
|
|
routeId
|
|
.split('/')
|
|
.slice(1)
|
|
.some((part) => !part || part === '.' || part === '..'))
|
|
)
|
|
return 'admin';
|
|
|
|
if (within(routeId, '/api/agent')) return 'machine';
|
|
if (routeId === '/api/afk/corrections') return 'admin';
|
|
if (['/api/bank/[action]', '/api/afk/[action]'].includes(routeId) && method === 'POST')
|
|
return 'machine';
|
|
if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
|
|
if (routeId === '/api/moderation/runtime' && method === 'POST') return 'machine';
|
|
if (routeId === '/api/leaderboard' && reading) return 'public';
|
|
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
|
|
if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST')
|
|
return 'public';
|
|
if (
|
|
routeId === '/account' ||
|
|
['/api/auth/me', '/api/auth/logout', '/api/auth/password'].includes(routeId)
|
|
)
|
|
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, '/moderation') || within(routeId, '/api/moderation'))
|
|
return reading ? 'moderation-read' : 'moderation';
|
|
|
|
if (
|
|
within(routeId, '/api/users') ||
|
|
within(routeId, '/settings') ||
|
|
within(routeId, '/api/settings') ||
|
|
within(routeId, '/api/server-keys') ||
|
|
within(routeId, '/server-keys') ||
|
|
within(routeId, '/keys')
|
|
)
|
|
return reading ? 'admin-read' : 'admin';
|
|
|
|
if (!reading && ['/api/hosts', '/api/game-files'].some((base) => within(routeId, base)))
|
|
return 'admin';
|
|
if (routeId === '/api/provisioning/[id]/override') return 'admin';
|
|
// These endpoints mix operational actions with global policy and schedule
|
|
// changes. Per-incident/job actions remain available to operators.
|
|
if (!reading && ['/api/maintenance', '/api/plutonium', '/api/watchdog'].includes(routeId))
|
|
return 'admin';
|
|
|
|
// These reads initiate fleet file scans or spend external API quota.
|
|
if (
|
|
routeId === '/api/drift' ||
|
|
routeId === '/api/plugins/github/release' ||
|
|
routeId === '/api/plugins/updates'
|
|
)
|
|
return 'operator';
|
|
|
|
// Summaries remain visible to viewers; raw configuration, historical file
|
|
// bytes, and unfiltered diagnostics may contain runtime credentials.
|
|
if (
|
|
routeId === '/configs/[id]' ||
|
|
routeId === '/api/configs/[id]' ||
|
|
routeId === '/api/configs/inspect' ||
|
|
routeId === '/api/campaigns/[id]/file/[fileId]' ||
|
|
routeId === '/api/deployments/[id]/file/[fileId]' ||
|
|
routeId === '/api/betas/[id]/revisions/[revision]/files/[scriptId]' ||
|
|
routeId === '/api/servers/[name]/logs/[file]' ||
|
|
routeId === '/api/servers/[name]/telemetry-requests' ||
|
|
routeId === '/api/servers/[name]/rcon-diagnostics'
|
|
)
|
|
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', 'super_viewer', 'operator', 'moderator', '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 === 'moderation') return canModerate(role);
|
|
if (permission === 'moderation-read') return canReadModeration(role);
|
|
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';
|
|
}
|