463 lines
17 KiB
TypeScript
463 lines
17 KiB
TypeScript
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<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 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<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=?,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() };
|
||
},
|
||
};
|