Add moderation permissions and durable player records

This commit is contained in:
2026-10-15 21:06:08 -04:00
parent c1758cce25
commit c91d49c298
17 changed files with 699 additions and 21 deletions
+47 -2
View File
@@ -1,18 +1,61 @@
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 {
canModerate,
canReadModeration,
moderatorMaximumRestrictionMs,
type UserRole
} from '../../src/lib/auth.js';
import {
roleAllows,
routePermission,
type RoutePermission
} from '../../src/lib/server/auth/access.js';
const roles: UserRole[] = ['viewer', 'super_viewer', 'operator', 'admin'];
const roles: UserRole[] = ['viewer', 'super_viewer', 'operator', 'moderator', 'admin'];
const allows = (role: UserRole, route: string | null, method = 'GET') =>
roleAllows(role, routePermission(route, method));
describe('management route permissions', () => {
it.each([
'/moderation',
'/moderation/players/[guid]',
'/api/moderation',
'/api/moderation/players/[guid]',
'/api/moderation/sanctions/[id]/revoke'
])('limits private moderation access at %s without changing fleet privileges', (route) => {
for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
const reading = ['GET', 'HEAD'].includes(method);
expect(routePermission(route, method)).toBe(reading ? 'moderation-read' : 'moderation');
for (const role of roles) {
expect(allows(role, route, method)).toBe(
role === 'admin' || role === 'moderator' || (reading && role === 'super_viewer')
);
}
}
});
it('keeps moderators within safe summary reads and explicit moderation operations', () => {
expect(allows('moderator', '/api/fleet')).toBe(true);
expect(allows('moderator', '/api/servers/[name]/chat')).toBe(true);
expect(allows('moderator', '/api/servers', 'POST')).toBe(false);
expect(allows('moderator', '/api/users')).toBe(false);
expect(allows('moderator', '/api/configs/[id]')).toBe(false);
expect(allows('moderator', '/api/drift')).toBe(false);
expect(allows('moderator', '/api/moderation-extra', 'POST')).toBe(false);
expect(allows('moderator', '/api/auth/password', 'POST')).toBe(true);
for (const role of roles) {
expect(canModerate(role)).toBe(role === 'moderator' || role === 'admin');
expect(canReadModeration(role)).toBe(
role === 'moderator' || role === 'admin' || role === 'super_viewer'
);
}
expect(canModerate(null)).toBe(false);
expect(canReadModeration(undefined)).toBe(false);
expect(moderatorMaximumRestrictionMs).toBe(604_800_000);
});
it('denies Super Viewer writes across every registered management route', () => {
const routes = resolve('src/routes');
const entries = readdirSync(routes, { recursive: true, withFileTypes: true });
@@ -285,6 +328,8 @@ describe('authentication and machine boundaries', () => {
'operator',
'operator-read',
'admin-read',
'moderation',
'moderation-read',
'admin',
'machine'
] as RoutePermission[]) {
+27
View File
@@ -235,6 +235,18 @@ describe('browser authentication guard', () => {
['operator', '/api/users', 'GET', 403],
['admin', '/api/hosts/[id]', 'PATCH', 200],
['admin', '/api/users', 'GET', 200],
['moderator', '/api/moderation', 'GET', 200],
['moderator', '/api/moderation/sanctions', 'POST', 200],
['moderator', '/api/moderation/sanctions/[id]/revoke', 'POST', 200],
['admin', '/api/moderation/sanctions', 'POST', 200],
['super_viewer', '/api/moderation', 'HEAD', 200],
['super_viewer', '/api/moderation/sanctions', 'POST', 403],
['operator', '/api/moderation', 'GET', 403],
['operator', '/api/moderation/sanctions', 'POST', 403],
['viewer', '/api/moderation', 'GET', 403],
['moderator', '/api/servers', 'POST', 403],
['moderator', '/api/configs/[id]', 'GET', 403],
['moderator', '/api/users', 'GET', 403],
['super_viewer', '/api/users', 'GET', 200],
['super_viewer', '/api/users/audit', 'GET', 200],
['super_viewer', '/api/server-keys', 'HEAD', 200],
@@ -278,6 +290,21 @@ describe('browser authentication guard', () => {
}
}
);
it('protects moderation pages and Svelte page-data requests with the same role checks', async () => {
for (const role of ['viewer', 'super_viewer', 'operator', 'moderator', 'admin'] as const) {
signIn(role);
for (const path of ['/moderation', '/moderation/__data.json']) {
const call = requestFor('/moderation', { path });
if (role === 'viewer' || role === 'operator') {
await expect(call.invoke()).rejects.toMatchObject({ status: 403 });
expect(call.resolve).not.toHaveBeenCalled();
} else {
expect((await call.invoke()).status).toBe(200);
expect(call.resolve).toHaveBeenCalledOnce();
}
}
}
});
it.each(['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'])(
'permits only administrators to write settings and Super Viewers to read them',
async (method) => {
+82
View File
@@ -1,6 +1,7 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
migrateModeratorRole,
migrateRememberedSessions,
migrateSuperViewerRole
} from '../../src/lib/server/auth/schema.js';
@@ -153,3 +154,84 @@ describe('remembered session migration', () => {
expect(handle.pragma('foreign_key_check')).toEqual([]);
});
});
describe('moderator role migration', () => {
it('preserves all account roles and remembered sessions while extending the role constraint', () => {
const upgrade = migrations.find((entry) => entry.name === 'moderator authentication role')!;
runMigrations(handle, migrations.slice(0, upgrade.version - 1));
handle.exec(`
UPDATE auth_sessions SET remembered=1 WHERE user_id='admin';
INSERT INTO auth_users VALUES
('super','Super','Super','super-password-hash','super_viewer',0,0,10,20,NULL);
INSERT INTO auth_sessions VALUES ('super-token-hash','super',10,1000,100,1);
`);
const tables = ['auth_users', 'auth_audit', 'auth_rate_limits', 'servers'];
const original = tables.map(rows);
const sessions = handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all();
expect(() =>
handle.exec("UPDATE auth_users SET role='moderator' WHERE id='viewer'")
).toThrow('CHECK constraint failed');
runMigrations(handle, migrations);
expect(tables.map(rows)).toEqual(original);
expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual(
sessions
);
expect(handle.pragma('foreign_keys', { simple: true })).toBe(1);
expect(handle.pragma('foreign_key_check')).toEqual([]);
expect(handle.pragma('foreign_key_list(auth_sessions)')).toEqual([
expect.objectContaining({ table: 'auth_users', from: 'user_id', on_delete: 'CASCADE' })
]);
handle.exec("UPDATE auth_users SET role='moderator' WHERE id='viewer'");
const users = rows('auth_users');
const schemaVersion = handle.pragma('schema_version', { simple: true });
migrateModeratorRole(handle);
expect(rows('auth_users')).toEqual(users);
expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual(
sessions
);
expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion);
expect(() => handle.exec("UPDATE auth_users SET role='unknown' WHERE id='viewer'")).toThrow(
'CHECK constraint failed'
);
expect(() =>
handle.exec("UPDATE auth_users SET username='ADMINISTRATOR' WHERE id='viewer'")
).toThrow('UNIQUE constraint failed');
handle.exec("DELETE FROM auth_users WHERE id='super'");
expect(handle.prepare("SELECT * FROM auth_sessions WHERE user_id='super'").all()).toEqual(
[]
);
});
it('rejects an unsafe rebuild and rolls back the upgrade if existing references are broken', () => {
const upgrade = migrations.find((entry) => entry.name === 'moderator authentication role')!;
runMigrations(handle, migrations.slice(0, upgrade.version - 1));
const users = rows('auth_users');
const sessions = handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all();
expect(() => migrateModeratorRole(handle)).toThrow('foreign keys to be disabled');
expect(rows('auth_users')).toEqual(users);
expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual(
sessions
);
handle.pragma('foreign_keys = OFF');
handle.exec("INSERT INTO auth_sessions VALUES ('orphan','missing',10,1000,100,1)");
handle.pragma('foreign_keys = ON');
expect(() => runMigrations(handle, migrations)).toThrow('left broken references');
expect(handle.pragma('user_version', { simple: true })).toBe(upgrade.version - 1);
expect(handle.pragma('foreign_keys', { simple: true })).toBe(1);
expect(rows('auth_users')).toEqual(users);
expect(
handle.prepare("SELECT name FROM sqlite_master WHERE name='auth_users_moderator'").get()
).toBeUndefined();
handle.exec("DELETE FROM auth_sessions WHERE token_hash='orphan'");
runMigrations(handle, migrations);
expect(rows('auth_users')).toEqual(users);
expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual(
sessions
);
expect(handle.pragma('foreign_key_check')).toEqual([]);
});
});
+19 -3
View File
@@ -256,7 +256,7 @@ describe('password storage and verification', () => {
describe('administrator user management', () => {
it('rejects the reserved system username for every role while allowing distinct usernames', async () => {
const admin = seedUser();
for (const role of ['admin', 'super_viewer', 'operator', 'viewer'] as const) {
for (const role of ['admin', 'super_viewer', 'operator', 'moderator', 'viewer'] as const) {
for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) {
await expect(
auth.createUser(admin.id, { username, role, password })
@@ -275,7 +275,7 @@ describe('administrator user management', () => {
it('creates each supported role with a forced initial password change and rejects case-insensitive duplicate names', async () => {
const admin = seedUser();
for (const role of ['admin', 'super_viewer', 'operator', 'viewer'] as const) {
for (const role of ['admin', 'super_viewer', 'operator', 'moderator', 'viewer'] as const) {
const user = await auth.createUser(admin.id, {
username: role + 'User',
displayName: ' ' + role + ' user ',
@@ -295,7 +295,7 @@ describe('administrator user management', () => {
await expect(
auth.createUser(admin.id, { username: 'OPERATORUSER', role: 'viewer', password })
).rejects.toMatchObject({ status: 409 });
expect(auth.listUsers()).toHaveLength(5);
expect(auth.listUsers()).toHaveLength(6);
expect(JSON.stringify(auth.listAuthAudit())).not.toContain(password);
});
@@ -304,6 +304,7 @@ describe('administrator user management', () => {
const actors = [
seedUser('superviewer', 'super_viewer'),
seedUser('operator', 'operator'),
seedUser('moderator', 'moderator'),
seedUser('viewer', 'viewer'),
seedUser('disabledadmin', 'admin', { disabled: true }),
seedUser('temporaryadmin', 'admin', { mustChangePassword: true })
@@ -333,6 +334,9 @@ describe('administrator user management', () => {
await expect(
auth.updateUser(first.id, first.id, { role: 'operator' })
).rejects.toMatchObject({ status: 409 });
await expect(
auth.updateUser(first.id, first.id, { role: 'moderator' })
).rejects.toMatchObject({ status: 409 });
await expect(auth.updateUser(first.id, first.id, { disabled: true })).rejects.toMatchObject(
{ status: 409 }
);
@@ -347,6 +351,18 @@ describe('administrator user management', () => {
expect(auth.getUser(second.id)?.role).toBe('admin');
});
it('revokes sessions when moderation access is granted or removed', async () => {
const admin = seedUser();
const target = seedUser('target', 'viewer');
const beforeGrant = auth.createSession(target.id, true);
await auth.updateUser(admin.id, target.id, { role: 'moderator' });
expect(auth.getUser(target.id)?.role).toBe('moderator');
expect(auth.readSession(beforeGrant.token)).toBeNull();
const beforeRemoval = auth.createSession(target.id, true);
await auth.updateUser(admin.id, target.id, { role: 'viewer' });
expect(auth.readSession(beforeRemoval.token)).toBeNull();
});
it.each([false, true])(
'revokes every target session on role, enabled-state and password changes (remembered=%s)',
async (remembered) => {
+89
View File
@@ -0,0 +1,89 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { moderationGuid } from '../../src/lib/moderation.js';
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture();
});
afterEach(async () => fixture.cleanup());
it('keeps large identities exact and normalizes only known representations', () => {
expect(moderationGuid('t6', '110000100000001', 'rcon')).toBe('76561197960265729');
expect(moderationGuid('t6', '076561197960265729')).toBe('76561197960265729');
expect(moderationGuid('t5', 'DEADbeef', 'rcon')).toBe('DEADbeef');
expect(moderationGuid('t5', 'Some:opaque_ID')).toBe('Some:opaque_ID');
});
it('preserves aliases and moderation identity independently of gameplay retention', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { observeModerationPlayers } = await import('../../src/lib/server/moderation/core.js');
const handle = db();
observeModerationPlayers(
handle,
't6',
[{ guid: '000123', name: 'Old name' }],
'2026-10-01T00:00:00.000Z'
);
observeModerationPlayers(
handle,
't6',
[{ guid: '123', name: 'New name' }],
'2026-10-02T00:00:00.000Z'
);
observeModerationPlayers(
handle,
't6',
[{ guid: '123', name: 'Delayed name' }],
'2026-09-30T00:00:00.000Z'
);
handle.prepare('DELETE FROM leaderboard_players').run();
expect(handle.prepare('SELECT * FROM moderation_players').get()).toMatchObject({
game: 't6',
guid: '123',
name: 'New name',
first_seen_at: '2026-09-30T00:00:00.000Z',
last_seen_at: '2026-10-02T00:00:00.000Z'
});
expect(handle.prepare('SELECT name FROM moderation_aliases ORDER BY name').all()).toEqual([
{ name: 'Delayed name' },
{ name: 'New name' },
{ name: 'Old name' }
]);
});
it('records mutation results atomically and rejects reused keys or revoked access', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { moderationMutation, moderationEvent } =
await import('../../src/lib/server/moderation/core.js');
const handle = db();
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES('moderator','moderator','Moderator','unused','moderator',0,0)`
)
.run();
const body = { op: 'note', idempotencyKey: 'request-1', text: 'A note' };
const work = vi.fn((actor, now) => {
moderationEvent(actor, 'note', { game: 't5', guid: '123' }, null, 'A note', now);
return { id: 'one' };
});
expect(moderationMutation('moderator', body, work)).toEqual({ ok: true, id: 'one' });
expect(moderationMutation('moderator', body, work)).toEqual({ ok: true, id: 'one' });
expect(work).toHaveBeenCalledTimes(1);
expect(() => moderationMutation('moderator', { ...body, text: 'Changed' }, work)).toThrow(
'already used'
);
expect(() =>
moderationMutation('moderator', { ...body, idempotencyKey: 'failing' }, (actor, now) => {
moderationEvent(actor, 'note', {}, null, 'Rolled back', now);
throw new Error('Failed');
})
).toThrow('Failed');
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_events').get()).toEqual({
count: 1
});
handle.prepare("UPDATE auth_users SET role='viewer' WHERE id='moderator'").run();
expect(() => moderationMutation('moderator', body, work)).toThrow('Moderation access');
});
+13 -6
View File
@@ -45,12 +45,15 @@ describe('administrator pages', () => {
expect(body).not.toContain('You do not have access');
});
it.each(['viewer', 'operator'] as const)('keeps user accounts unavailable to %s', (role) => {
asRole(role);
const body = render(UsersPage).body;
expect(body).toContain('You do not have access');
expect(body).not.toContain('Account activity');
});
it.each(['viewer', 'operator', 'moderator'] as const)(
'keeps user accounts unavailable to %s',
(role) => {
asRole(role);
const body = render(UsersPage).body;
expect(body).toContain('You do not have access');
expect(body).not.toContain('Account activity');
}
);
it('retains administrator user creation and describes the assignable Super Viewer role', () => {
asRole('admin');
@@ -58,6 +61,10 @@ describe('administrator pages', () => {
expect(body).toContain('Add user');
expect(body).toContain('Super Viewer');
expect(body).toContain('Read all fleet and administrator pages without making changes.');
expect(body).toContain('Moderator');
expect(body).toContain(
'Moderate players with server or game restrictions lasting up to 7 days.'
);
});
it('keeps key searches available while hiding key creation from Super Viewers', () => {