Files
shd.build/src/routes/isac/audit/+page.server.ts
T
jb 57068b61cd
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy staging / deploy (push) Successful in 1m3s
feat: expand ISAC content modeling
2026-08-03 01:23:20 -04:00

463 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() };
},
};