Rename admin routes to ISAC
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPagination } 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<string, any> | 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<string, unknown>)
|
||||
.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<string, unknown> | null, expected: Record<string, unknown> | 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 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 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
|
||||
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 audit_log.created_at DESC 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
|
||||
};
|
||||
};
|
||||
|
||||
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<string, unknown> | null = null;
|
||||
let restored: Record<string, unknown> | 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=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,
|
||||
weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`))
|
||||
.run(before.name,before.type,before.category,before.calculation_type,before.unit,before.gear_min,before.gear_max,before.weapon_min,before.weapon_max,before.skill_chip_min,before.skill_chip_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() };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user