import { getPermissions, requirePermission, } from '$lib/server/auth/authorization'; import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; import { getPagination, getRegistrySort } from '$lib/server/pagination'; import { recordIsacAudit } from '$lib/server/audit'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; const reversibleActions = new Set([ 'attribute.updated', 'attribute.archived', 'brand_set.archived', 'gear.archived', 'weapon.archived', 'role.updated', 'access.updated', ]); const parseSnapshot = (value: unknown): Record | null => { if (typeof value !== 'string' || !value) return null; try { const parsed = JSON.parse(value); return parsed && typeof parsed === 'object' ? parsed : null; } catch { return null; } }; const normalize = (value: unknown): unknown => { if (Array.isArray(value)) { const normalized = value.map(normalize); return normalized.every((item) => ['string', 'number', 'boolean'].includes(typeof item), ) ? normalized.sort((left, right) => String(left).localeCompare(String(right)), ) : normalized; } if (!value || typeof value !== 'object') return value; return Object.fromEntries( Object.entries(value as Record) .filter(([key]) => !['created_at', 'updated_at'].includes(key)) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, item]) => [key, normalize(item)]), ); }; const comparable = (value: unknown): string => JSON.stringify(normalize(value)); const sameRecord = ( current: Record | null, expected: Record | null, ) => { if (!current || !expected) return false; return Object.entries(expected).every( ([key, value]) => !['created_at', 'updated_at'].includes(key) && comparable(current[key]) === comparable(value), ); }; export const load: PageServerLoad = async ({ cookies, url }) => { const operator = await requirePermission( await getSessionUser(cookies), 'audit.view', ); const canRevert = (await getPermissions(operator)).includes('*') || (await getPermissions(operator)).includes('audit.revert'); const database = await getDatabase(); const query = url.searchParams.get('q')?.trim().slice(0, 120) ?? ''; const category = url.searchParams.get('category')?.trim() ?? ''; const action = url.searchParams.get('action')?.trim() ?? ''; const sort = getRegistrySort( url, { event: { asc: 'audit_log.action COLLATE NOCASE ASC,audit_log.entity_type COLLATE NOCASE ASC', desc: 'audit_log.action COLLATE NOCASE DESC,audit_log.entity_type COLLATE NOCASE DESC', }, operator: { asc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE ASC', desc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE DESC', }, target: { asc: 'audit_log.entity_id COLLATE NOCASE ASC', desc: 'audit_log.entity_id COLLATE NOCASE DESC', }, time: { asc: 'audit_log.created_at ASC', desc: 'audit_log.created_at DESC', defaultDirection: 'desc', }, }, 'time', 'desc', ); const conditions: string[] = []; const args: unknown[] = []; if (query) { conditions.push(`(audit_log.entity_id LIKE ? ESCAPE '\\' COLLATE NOCASE OR audit_log.before_json LIKE ? ESCAPE '\\' COLLATE NOCASE OR audit_log.after_json LIKE ? ESCAPE '\\' COLLATE NOCASE OR users.username LIKE ? ESCAPE '\\' COLLATE NOCASE OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE)`); const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`; args.push(pattern, pattern, pattern, pattern, pattern); } if (category === 'user') conditions.push( "audit_log.entity_type IN ('user','user_access','user_moderation','identity')", ); else if (category === 'access_policy') conditions.push("audit_log.entity_type IN ('role','role_management')"); else if (category) { conditions.push('audit_log.entity_type=?'); args.push(category); } if (action) { conditions.push('audit_log.action=?'); args.push(action); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const [total, categories, actions, summary] = await Promise.all([ ( await database.prepare(`SELECT COUNT(*) AS total FROM audit_log JOIN users ON users.id=audit_log.actor_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id ${where}`) ).get(...args), ( await database.prepare( 'SELECT entity_type,COUNT(*) AS count FROM audit_log GROUP BY entity_type ORDER BY entity_type', ) ).all(), ( await database.prepare( 'SELECT action,COUNT(*) AS count FROM audit_log GROUP BY action ORDER BY action', ) ).all(), ( await database.prepare(`SELECT COUNT(*) AS total, SUM(CASE WHEN entity_type='game_data' THEN 1 ELSE 0 END) AS game_data, SUM(CASE WHEN entity_type='data_moderation' THEN 1 ELSE 0 END) AS data_moderation, SUM(CASE WHEN entity_type='community_moderation' THEN 1 ELSE 0 END) AS community_moderation, SUM(CASE WHEN entity_type IN ('user','user_access','user_moderation','identity') THEN 1 ELSE 0 END) AS users, SUM(CASE WHEN entity_type IN ('role','role_management') THEN 1 ELSE 0 END) AS access, SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration, SUM(CASE WHEN entity_type='calculation_configuration' THEN 1 ELSE 0 END) AS calculation_configuration, SUM(CASE WHEN entity_type='ai_assistance' THEN 1 ELSE 0 END) AS ai_assistance FROM audit_log`) ).get(), ]); const pagination = getPagination(url, Number(total?.total ?? 0)); const entries = await ( await database.prepare(`SELECT audit_log.*, COALESCE(user_profiles.display_name,users.username) AS actor_name,users.login_username AS actor_login FROM audit_log JOIN users ON users.id=audit_log.actor_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id ${where} ORDER BY ${sort.orderBy},audit_log.id LIMIT ? OFFSET ?`) ).all(...args, pagination.perPage, pagination.offset); const revertedIds = entries.length ? await ( await database.prepare(`SELECT entity_id FROM audit_log WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id IN (${entries.map(() => '?').join(',')})`) ).all(...entries.map((entry) => entry.id)) : []; const reverted = new Set(revertedIds.map((entry) => String(entry.entity_id))); return { entries: entries.map((entry) => ({ ...entry, reversible: canRevert && reversibleActions.has(String(entry.action)) && Boolean(entry.before_json) && !reverted.has(String(entry.id)), reverted: reverted.has(String(entry.id)), })), categories, actions, summary, pagination, filters: { query, category, action }, canRevert, sort, }; }; export const actions: Actions = { revert: async ({ cookies, request }) => { const operator = await requirePermission( await getSessionUser(cookies), 'audit.revert', ); const operatorPermissions = await getPermissions(operator); const isFullAdmin = operatorPermissions.includes('*'); const eventId = String((await request.formData()).get('eventId') ?? ''); const database = await getDatabase(); const event = await ( await database.prepare('SELECT * FROM audit_log WHERE id=?') ).get(eventId); if (!event) return fail(404, { revertError: 'Audit event not found.' }); if (!reversibleActions.has(String(event.action)) || !event.before_json) return fail(400, { revertError: 'This event does not contain a supported reversible change.', }); if ( await ( await database.prepare( "SELECT id FROM audit_log WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id=?", ) ).get(eventId) ) { return fail(409, { revertError: 'This audit event has already been reverted.', }); } const before = parseSnapshot(event.before_json), after = parseSnapshot(event.after_json); if (!before || !after) return fail(400, { revertError: 'The stored snapshots cannot be safely interpreted.', }); const action = String(event.action), targetId = String(event.entity_id); let current: Record | null = null; let restored: Record | null = null; try { if (action.startsWith('attribute.')) { current = (await ( await database.prepare('SELECT * FROM stat_attributes WHERE id=?') ).get(targetId)) ?? null; if (!sameRecord(current, after)) return fail(409, { revertError: 'The attribute changed after this event. Reversion was blocked to preserve newer work.', }); await ( await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?, weapon_min=?,weapon_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`) ).run( before.name, before.description ?? null, before.type, before.category, before.calculation_type, before.unit, before.gear_min, before.gear_max, before.weapon_min, before.weapon_max, before.cap_value, before.active, new Date().toISOString(), targetId, ); restored = (await ( await database.prepare('SELECT * FROM stat_attributes WHERE id=?') ).get(targetId)) ?? null; } else if (action.endsWith('.archived')) { const table = action.startsWith('brand_set.') ? 'brand_sets' : action.startsWith('gear.') ? 'gear' : action.startsWith('weapon.') ? 'weapons' : ''; if (!table) return fail(400, { revertError: 'Unsupported archive category.' }); current = (await ( await database.prepare(`SELECT * FROM ${table} WHERE id=?`) ).get(targetId)) ?? null; if (!sameRecord(current, after)) return fail(409, { revertError: 'The record changed after it was archived. Reversion was blocked.', }); await ( await database.prepare( `UPDATE ${table} SET active=?,updated_at=? WHERE id=?`, ) ).run(before.active, new Date().toISOString(), targetId); restored = (await ( await database.prepare(`SELECT * FROM ${table} WHERE id=?`) ).get(targetId)) ?? null; } else if (action === 'role.updated') { const currentRole = await ( await database.prepare( 'SELECT name,description FROM roles WHERE id=?', ) ).get(targetId); const currentPermissions = await ( await database.prepare( `SELECT permissions.name FROM role_permissions JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_id=? ORDER BY permissions.name`, ) ).all(targetId); current = { ...currentRole, permissions: currentPermissions.map((row) => row.name), }; if (comparable(current) !== comparable(after)) return fail(409, { revertError: 'The role changed after this event. Reversion was blocked.', }); const permissionNames = Array.isArray(before.permissions) ? before.permissions.map(String) : []; if (permissionNames.includes('system.admin') && !isFullAdmin) return fail(403, { revertError: 'Only a full administrator can restore unrestricted system access.', }); const permissionRows = permissionNames.length ? await ( await database.prepare( `SELECT id,name FROM permissions WHERE name IN (${permissionNames.map(() => '?').join(',')})`, ) ).all(...permissionNames) : []; if (permissionRows.length !== permissionNames.length) return fail(409, { revertError: 'A previous permission no longer exists.', }); await ( await database.prepare( 'UPDATE roles SET name=?,description=? WHERE id=?', ) ).run(before.name, before.description ?? null, targetId); await ( await database.prepare('DELETE FROM role_permissions WHERE role_id=?') ).run(targetId); for (const permission of permissionRows) await ( await database.prepare( 'INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)', ) ).run(targetId, permission.id); restored = { name: before.name, description: before.description ?? null, permissions: permissionNames, }; } else if (action === 'access.updated') { const currentRoles = await ( await database.prepare( 'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id', ) ).all(targetId); const currentPermissions = await ( await database.prepare( 'SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id', ) ).all(targetId); current = { roleIds: currentRoles.map((row) => row.role_id), permissionIds: currentPermissions.map((row) => row.permission_id), }; if (comparable(current) !== comparable(after)) return fail(409, { revertError: 'The user’s access changed after this event. Reversion was blocked.', }); const roleIds = Array.isArray(before.roleIds) ? before.roleIds.map(String) : []; const permissionIds = Array.isArray(before.permissionIds) ? before.permissionIds.map(String) : []; const restoresAdminRole = roleIds.includes('role-admin'); const restoresSystemPermission = permissionIds.includes( 'permission-system-admin', ); if ((restoresAdminRole || restoresSystemPermission) && !isFullAdmin) return fail(403, { revertError: 'Only a full administrator can restore administrator access.', }); const roleCount = roleIds.length ? Number( ( await ( await database.prepare( `SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`, ) ).get(...roleIds) )?.count, ) : 0; const permissionCount = permissionIds.length ? Number( ( await ( await database.prepare( `SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`, ) ).get(...permissionIds) )?.count, ) : 0; if ( roleCount !== roleIds.length || permissionCount !== permissionIds.length ) return fail(409, { revertError: 'A previous role or permission no longer exists.', }); await ( await database.prepare('DELETE FROM user_roles WHERE user_id=?') ).run(targetId); await ( await database.prepare('DELETE FROM user_permissions WHERE user_id=?') ).run(targetId); for (const roleId of roleIds) await ( await database.prepare( 'INSERT INTO user_roles(user_id,role_id) VALUES(?,?)', ) ).run(targetId, roleId); for (const permissionId of permissionIds) await ( await database.prepare( 'INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)', ) ).run(targetId, permissionId); restored = { roleIds, permissionIds }; } else { return fail(400, { revertError: 'This change requires a specialized recovery workflow that is not available yet.', }); } } catch (error) { if (error && typeof error === 'object' && 'status' in error) throw error; return fail(409, { revertError: 'The previous state could not be restored because related data has changed.', }); } await recordIsacAudit({ actorId: operator.id, category: 'audit_control', action: 'audit.reverted', entityId: eventId, before: current, after: { originalEventId: eventId, originalAction: action, targetId, restored, }, }); return { reverted: true, resultToken: randomUUID() }; }, };