Add comprehensive ISAC audit logging

This commit is contained in:
JB
2026-07-25 03:48:22 -04:00
parent bb9ee3785c
commit 6be31de986
19 changed files with 252 additions and 40 deletions
+40
View File
@@ -49,6 +49,46 @@ All ISAC data-management editors must follow the same successful-save behavior:
Apply this convention whenever an ISAC editor is added or materially changed.
## ISAC audit logging
Every successful server-side mutation performed through ISAC must create an
append-only audit event. Audit coverage is a required part of implementing or
changing any ISAC module, action, endpoint, bulk operation, moderation control,
identity operation, access-policy change, archive action, or destructive action.
Follow these rules:
1. Use the shared audit utilities in `src/lib/server/audit.ts`; do not invent
module-specific audit formats or write directly to `audit_log` in new code.
2. Record the authenticated operator ID, stable category, specific action,
target entity ID, timestamp, and useful before/after snapshots.
3. Categories describe durable operational domains such as `game_data`,
`user_access`, `user_moderation`, `identity`, and `role_management`.
Actions describe the exact event in namespaced past tense, such as
`weapon.updated`, `attribute.archived`, or `moderation.blocked`.
4. Capture relationship changes as well as the primary row. When an action
replaces permission sets, compatible brands, talents, effects, tiers, or
other linked records, include the meaningful previous and resulting sets.
5. Create audit records only after validation succeeds and the requested
mutation has completed. Rejected, unauthorized, or validation-failed
requests must not appear as successful mutation events.
6. Never include password hashes, session tokens, magic-link tokens, OAuth
state, passkey public-key bytes, SMTP credentials, environment secrets, or
other authentication secrets in audit snapshots. Minimize personal data to
what is necessary for security, support, and accountability.
7. Audit visibility is controlled by `audit.view`. The read-only `Auditor` role
grants `isac.access` and `audit.view`; it must not implicitly grant mutation,
moderation, identity-management, or user-access permissions.
8. New audit categories and actions must remain searchable in the Audit History
registry. Update its grouped filters when a new durable operational domain is
introduced.
9. Preserve audit history during normal record archival and soft deletion.
Never add an ISAC action that edits or deletes audit records without an
explicit retention policy, legal review, and separately audited authority.
10. Tests and handoff verification for an ISAC mutation must include confirming
that its audit event contains the correct actor, category, action, target,
and change evidence.
## Database preservation
Development data is valuable and may be entered by non-technical contributors.
+4
View File
@@ -122,6 +122,7 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-roles','users.roles','Assign roles and direct permissions');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-delete','users.delete','Soft-delete user accounts while retaining authored content');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-identities','users.identities','View and manage linked external identities');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-view','audit.view','View and search the immutable ISAC audit history');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-isac-access');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-manage');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-operator','permission-isac-access');
@@ -132,6 +133,9 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-acces
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-access-manager','permission-users-roles');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-access-manager','permission-users-identities');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-admin','permission-system-admin');
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-auditor','Auditor','Read-only access to ISAC audit history');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-auditor','permission-isac-access');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-auditor','permission-audit-view');
CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS discord_profiles (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+6
View File
@@ -216,6 +216,12 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps
must never be able to grant `system.admin`; both option visibility and
server-side validation enforce this boundary. Role mutations are recorded in
the audit log with their previous and resulting permission sets.
- Audit History belongs to a separate `SYSTEM OVERSIGHT` navigation domain.
Audit records are append-only operational evidence and show the operator,
categorical action, target identifier, timestamp, and expandable before/after
JSON snapshots. Search and category/action filters run before pagination.
The Auditor role grants read-only `audit.view` access plus entry to ISAC;
audit visibility never implies mutation, user-management, or game-data access.
- Account deletion uses a collapsed danger zone, explicit identity confirmation,
and plain-language retention guidance. Soft-deleted accounts retain authored
content under a deleted-agent identity while releasing credentials, usernames,
+2 -1
View File
@@ -104,5 +104,6 @@
@media(max-width:1200px){.weapon-identity-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.weapon-fixed-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.weapon-tier-grid{grid-template-columns:1fr}.weapon-talent-pools{grid-template-columns:1fr}}@media(max-width:700px){.weapon-identity-grid,.weapon-fixed-grid{grid-template-columns:1fr 1fr}.compact-selection{grid-template-columns:1fr}.base-bonus-row,.augment-effect,.augment-effect:not(:has(>button)){grid-template-columns:30px 1fr}.base-bonus-row .field:nth-of-type(2),.augment-effect .field:nth-of-type(2){grid-column:2}.base-bonus-row>button,.augment-effect>button{grid-column:2}.augment-sequence{grid-template-columns:1fr 1fr}}@media(max-width:480px){.weapon-identity-grid,.weapon-fixed-grid{grid-template-columns:1fr}.weapon-range-row{grid-template-columns:45px 1fr 1fr}}
.legal-table-wrap{overflow-x:auto;margin:18px 0}.legal-table{width:100%;min-width:720px;border-collapse:collapse;border:1px solid var(--shd-border);background:#0b0e0f}.legal-table th,.legal-table td{padding:12px 14px;border:1px solid var(--shd-border);text-align:left;vertical-align:top}.legal-table th{color:var(--shd-orange);background:#111516;font-size:7px;letter-spacing:1px}.legal-table td{color:#a1a7a8;font:12px/1.55 Georgia,serif}.legal-table code,.legal-document code{color:#d9dedf;font:11px Consolas,monospace}.cookie-notice{position:fixed;z-index:1000;right:24px;bottom:24px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:22px;width:min(620px,calc(100vw - 48px));padding:18px 20px;border:1px solid var(--shd-orange);background:rgba(10,13,14,.98);box-shadow:0 14px 48px #000c,4px 0 0 var(--shd-orange)}.cookie-notice .eyebrow{margin:0 0 8px}.cookie-notice div>p:not(.eyebrow){margin:0;color:var(--shd-text-muted);font:12px/1.5 Georgia,serif}.cookie-notice a{display:inline-block;margin-top:10px;color:var(--shd-orange);font-size:7px;font-weight:900;letter-spacing:1px}.cookie-notice button{min-width:84px}@media(max-width:600px){.cookie-notice{right:12px;bottom:12px;grid-template-columns:1fr;width:calc(100vw - 24px)}.cookie-notice button{width:100%}}
.kpi-filters{height:auto}.kpi-filters>a{position:relative;min-height:70px;padding:11px 13px;background:#111415;border:1px solid #2d3233;border-top:2px solid #3a4041;display:grid;color:inherit;cursor:pointer}.kpi-filters>a:hover,.kpi-filters>a:focus-visible{border-color:#8a672b;border-top-color:var(--shd-orange);background:#171a1b}.kpi-filters>a.active{border-color:#8a672b;border-top-color:var(--shd-orange);background:#211a0e;box-shadow:inset 0 -2px #f6a821}.kpi-filters>a:active{transform:translateY(1px);filter:brightness(.88)}.kpi-filters>a span{font-size:7px;letter-spacing:1.3px;color:#737a7c}.kpi-filters>a strong{font-size:23px;line-height:1.1;margin:3px 0}.kpi-filters>a small{font-size:7px;color:#555d5f}.kpi-filters>a.active span,.kpi-filters>a.active strong{color:var(--shd-orange)}
.role-identity{grid-template-columns:minmax(220px,.7fr) minmax(320px,1.3fr)!important}.permission-catalogue{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:8px}.permission-catalogue label{min-height:54px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:9px;padding:8px 10px;border:1px solid #303536;background:#0d1011;cursor:pointer}.permission-catalogue label:hover,.permission-catalogue label:focus-within{border-color:#8a672b;background:#151819}.permission-catalogue label:active{transform:translateY(1px);filter:brightness(.88)}.permission-catalogue input{width:auto;margin:0;accent-color:var(--shd-orange)}.permission-catalogue span{display:grid;gap:4px}.permission-catalogue b{color:#d7d9d5;font-size:9px}.permission-catalogue small{color:#626a6c;font-size:7px;line-height:1.35}.permission-catalogue em{color:#596164;font-size:6px;font-style:normal;font-weight:900;letter-spacing:.7px}.permission-catalogue label:has(input:checked){border-color:#6e5226;background:#211a0e}.permission-catalogue label:has(input:checked) em{color:var(--shd-orange)}.role-row{min-width:960px;grid-template-columns:1.1fr .55fr 2fr .65fr .3fr}.role-permission-summary{display:flex!important;flex-wrap:wrap;gap:3px}.role-permission-summary b{padding:3px 5px;border:1px solid #3a4840;color:#75c08c;font-size:6px;letter-spacing:.5px}@media(max-width:700px){.permission-catalogue,.role-identity{grid-template-columns:1fr!important}}
.role-kpis{grid-template-columns:repeat(5,minmax(105px,1fr))}.role-identity{grid-template-columns:minmax(220px,.7fr) minmax(320px,1.3fr)!important}.permission-catalogue{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:8px}.permission-catalogue label{min-height:54px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:9px;padding:8px 10px;border:1px solid #303536;background:#0d1011;cursor:pointer}.permission-catalogue label:hover,.permission-catalogue label:focus-within{border-color:#8a672b;background:#151819}.permission-catalogue label:active{transform:translateY(1px);filter:brightness(.88)}.permission-catalogue input{width:auto;margin:0;accent-color:var(--shd-orange)}.permission-catalogue span{display:grid;gap:4px}.permission-catalogue b{color:#d7d9d5;font-size:9px}.permission-catalogue small{color:#626a6c;font-size:7px;line-height:1.35}.permission-catalogue em{color:#596164;font-size:6px;font-style:normal;font-weight:900;letter-spacing:.7px}.permission-catalogue label:has(input:checked){border-color:#6e5226;background:#211a0e}.permission-catalogue label:has(input:checked) em{color:var(--shd-orange)}.role-row{min-width:960px;grid-template-columns:1.1fr .55fr 2fr .65fr .3fr}.role-permission-summary{display:flex!important;flex-wrap:wrap;gap:3px}.role-permission-summary b{padding:3px 5px;border:1px solid #3a4840;color:#75c08c;font-size:6px;letter-spacing:.5px}@media(max-width:1000px){.role-kpis{grid-template-columns:repeat(3,minmax(105px,1fr))}}@media(max-width:700px){.permission-catalogue,.role-identity{grid-template-columns:1fr!important}.role-kpis{grid-template-columns:1fr 1fr}}
.audit-filter{height:auto;display:grid;grid-template-columns:minmax(220px,1fr) 210px 240px auto;gap:7px;margin:10px 0}.audit-filter input,.audit-filter select,.audit-filter button{height:40px;margin:0}.audit-row{min-width:980px;grid-template-columns:1fr 1fr 1.3fr .85fr .35fr}.audit-entry{border-top:1px solid #272c2d}.audit-entry:first-of-type{border-top:0}.audit-entry summary{border:0;cursor:pointer;list-style:none}.audit-entry summary::-webkit-details-marker{display:none}.audit-entry summary:hover{background:#15191a}.audit-entry summary:active{transform:translateY(1px);filter:brightness(.88)}.audit-entry summary>b{justify-self:end;color:var(--shd-orange);font-size:7px;letter-spacing:.7px}.audit-entry[open] summary{background:#171a1b;box-shadow:inset 2px 0 var(--shd-orange)}.audit-comparison{display:grid;grid-template-columns:1fr 1fr;gap:1px;padding:1px;background:#303536}.audit-comparison section{min-width:0;padding:12px;background:#0b0e0f}.audit-comparison span{color:var(--shd-orange);font-size:7px;font-weight:900;letter-spacing:1px}.audit-comparison pre{max-height:320px;overflow:auto;margin:9px 0 0;color:#aeb4b5;font:10px/1.5 Consolas,monospace;white-space:pre-wrap;word-break:break-word}@media(max-width:900px){.audit-filter{grid-template-columns:1fr 1fr}.audit-comparison{grid-template-columns:1fr}}@media(max-width:560px){.audit-filter{grid-template-columns:1fr}}
+3 -3
View File
@@ -2,12 +2,12 @@
import { page } from '$app/state';
type Item = { value: string; label: string; count: string | number; description: string };
let { items, active = '', className = '' }: { items: Item[]; active?: string; className?: string } = $props();
let { items, active = '', className = '', parameter = 'filter' }: { items: Item[]; active?: string; className?: string; parameter?: string } = $props();
function href(value: string) {
const params = new URLSearchParams(page.url.searchParams);
if (value) params.set('filter', value);
else params.delete('filter');
if (value) params.set(parameter, value);
else params.delete(parameter);
params.delete('page');
const query = params.toString();
return `${page.url.pathname}${query ? `?${query}` : ''}`;
+35
View File
@@ -0,0 +1,35 @@
import { getDatabase } from '$lib/server/database';
import { randomUUID } from 'node:crypto';
type AuditValue = Record<string, unknown> | unknown[] | null;
export async function recordIsacAudit(input: {
actorId: string;
category: string;
action: string;
entityId: string;
before?: AuditValue;
after?: AuditValue;
}) {
const database = await getDatabase();
await (await database.prepare(`INSERT INTO audit_log
(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at)
VALUES(?,?,?,?,?,?,?,?)`)).run(
randomUUID(),
input.actorId,
input.category,
input.entityId,
input.action,
input.before === undefined ? null : JSON.stringify(input.before),
input.after === undefined ? null : JSON.stringify(input.after),
new Date().toISOString()
);
}
export async function auditRecord(table: string, id: string): Promise<Record<string, unknown> | null> {
if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'roles'].includes(table)) {
throw new Error('Unsupported audit table');
}
const database = await getDatabase();
return await (await database.prepare(`SELECT * FROM ${table} WHERE id=?`)).get(id) ?? null;
}
+1 -1
View File
@@ -47,7 +47,7 @@ export async function softDeleteAccount(
)).run(
randomUUID(),
deletedBy,
'user',
'user_access',
userId,
'account.soft_deleted',
purgeSecurityData ? JSON.stringify({ purgedAtOwnerRequest: true }) : JSON.stringify({ username: user.username, displayName: user.display_name }),
+6
View File
@@ -14,6 +14,8 @@
{ href: '/admin/roles', label: 'Roles & permissions', code: 'ROLE', permission: 'users.roles' }
];
const visibleUserSections = $derived(userSections.filter((section) => can(section.permission)));
const oversightSections = [{ href: '/admin/audit', label: 'Audit history', code: 'AUDT', permission: 'audit.view' }];
const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission)));
</script>
<main class="admin-page">
@@ -33,6 +35,10 @@
{#each dataSections as section}<a class:active={page.url.pathname === section.href} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>{/each}
</nav>
{/if}
{#if visibleOversightSections.length}
<div class="sidebar-label section-label"><span>SYSTEM OVERSIGHT</span><b>01 MODULE</b></div>
<nav aria-label="System oversight">{#each visibleOversightSections as section}<a class:active={page.url.pathname.startsWith(section.href)} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>{/each}</nav>
{/if}
<div class="sidebar-foot"><span>ISAC NODE</span><strong>NYC-01</strong><small>Access policy synchronized</small></div>
</aside>
<section class="admin-content">{@render children()}</section>
+1
View File
@@ -8,5 +8,6 @@ export const load: PageServerLoad = async ({ cookies }) => {
if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/admin/users');
if (permissions.includes('users.roles')) redirect(303, '/admin/roles');
if (permissions.includes('data.manage')) redirect(303, '/admin/stats');
if (permissions.includes('audit.view')) redirect(303, '/admin/audit');
redirect(303, '/account');
};
+47
View File
@@ -0,0 +1,47 @@
import { 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 type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'audit.view');
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 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);
return { entries, categories, actions, summary, pagination, filters: { query, category, action } };
};
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import KpiFilters from '$lib/components/KpiFilters.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
let { data } = $props();
const date = (value: unknown) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(String(value)));
const pretty = (value: unknown) => {
if (!value) return 'NO SNAPSHOT';
try { return JSON.stringify(JSON.parse(String(value)), null, 2); } catch { return String(value); }
};
</script>
<svelte:head><title>Audit history — ISAC</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">SYSTEM OVERSIGHT</p><h2>AUDIT HISTORY</h2><p>Immutable operator activity, target records, and before/after change evidence.</p></div></div>
<KpiFilters parameter="category" active={data.filters.category} items={[
{value:'',label:'ALL EVENTS',count:data.summary.total,description:'Complete audit history'},
{value:'game_data',label:'GAME DATA',count:data.summary.game_data,description:'Builder-source mutations'},
{value:'user',label:'USER OPERATIONS',count:data.summary.users,description:'Access, moderation, identities'},
{value:'access_policy',label:'ACCESS POLICY',count:data.summary.access,description:'Role definition changes'}
]}/>
<form class="audit-filter">
<input name="q" value={data.filters.query} placeholder="ACTOR, TARGET ID, OR CHANGED VALUE"/>
<select name="category"><option value="">ALL CATEGORIES</option><option value="user" selected={data.filters.category === 'user'}>USER OPERATIONS · ALL</option><option value="access_policy" selected={data.filters.category === 'access_policy'}>ACCESS POLICY · ALL</option>{#each data.categories as item}<option value={item.entity_type} selected={data.filters.category === item.entity_type}>{String(item.entity_type).replaceAll('_',' ').toUpperCase()} · {item.count}</option>{/each}</select>
<select name="action"><option value="">ALL ACTIONS</option>{#each data.actions as item}<option value={item.action} selected={data.filters.action === item.action}>{String(item.action).toUpperCase()} · {item.count}</option>{/each}</select>
<button class="secondary">SEARCH AUDIT</button>
</form>
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table">
<div class="dashboard-row audit-row table-heading"><span>EVENT</span><span>OPERATOR</span><span>TARGET</span><span>TIME</span><span></span></div>
{#each data.entries as entry}
<details class="audit-entry">
<summary class="dashboard-row audit-row"><div><strong>{String(entry.action).toUpperCase()}</strong><small>{String(entry.entity_type).replaceAll('_',' ').toUpperCase()}</small></div><div><strong>{entry.actor_name}</strong><small>{entry.actor_login ?? entry.actor_id}</small></div><div><strong>{entry.entity_id}</strong><small>EVENT {String(entry.id).slice(0,8).toUpperCase()}</small></div><span>{date(entry.created_at)}</span><b>DETAILS +</b></summary>
<div class="audit-comparison"><section><span>BEFORE</span><pre>{pretty(entry.before_json)}</pre></section><section><span>AFTER</span><pre>{pretty(entry.after_json)}</pre></section></div>
</details>
{:else}<div class="empty-dashboard"><strong>NO AUDIT EVENTS FOUND</strong><span>Adjust the category, action, or search value.</span></div>{/each}
</div>
+7 -2
View File
@@ -5,6 +5,7 @@ import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { getPagination } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
const thresholds = [2, 3, 4] as const;
@@ -37,7 +38,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
export const actions: Actions = {
save: async ({ request, cookies }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
@@ -54,6 +55,7 @@ export const actions: Actions = {
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${uniqueStatIds.map(() => '?').join(',')})`)).get(...uniqueStatIds);
if (Number(validStats?.count) !== uniqueStatIds.length) return fail(400, { brandError: 'One or more selected attributes are unavailable.' });
const brandId = id || randomUUID(), now = new Date().toISOString();
const before = id ? { record: await auditRecord('brand_sets', id), effects: await (await database.prepare('SELECT * FROM brand_set_effects WHERE brand_set_id=? ORDER BY pieces_required')).all(id) } : null;
if (id) await (await database.prepare('UPDATE brand_sets SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
else await (await database.prepare('INSERT INTO brand_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(brandId, name, now, now);
for (const effect of effects) {
@@ -61,12 +63,15 @@ export const actions: Actions = {
VALUES(?,?,?,?,?,?,?) ON CONFLICT(brand_set_id,pieces_required) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,value=excluded.value,updated_at=excluded.updated_at`
)).run(randomUUID(), brandId, effect.pieces, effect.statId, effect.value, now, now);
}
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'brand_set.updated' : 'brand_set.created', entityId: brandId, before, after: { record: await auditRecord('brand_sets', brandId), effects } });
return { brandSaved: true, brandMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const before = await auditRecord('brand_sets', id);
await (await (await getDatabase()).prepare('UPDATE brand_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'brand_set.archived', entityId: id, before, after: await auditRecord('brand_sets', id) });
return { brandArchived: true };
}
};
+7 -2
View File
@@ -5,6 +5,7 @@ import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { getPagination } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
@@ -51,7 +52,7 @@ const readRange = (form: FormData, prefix: string) => {
export const actions: Actions = {
save: async ({ request, cookies }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
@@ -87,6 +88,7 @@ export const actions: Actions = {
if (Number(validTalents?.count) !== talentIds.length) return fail(400, { gearError: `Every selected talent must be a ${slot} talent.` });
}
const gearId = id || randomUUID(), now = new Date().toISOString();
const before = id ? { record: await auditRecord('gear', id), brandIds: await (await database.prepare('SELECT brand_set_id FROM gear_brand_sets WHERE gear_id=?')).all(id), talentIds: await (await database.prepare('SELECT talent_id FROM gear_talents WHERE gear_id=?')).all(id) } : null;
if (id) {
await (await database.prepare(`UPDATE gear SET name=?,slot=?,tier=?,level=?,base_health_min=?,base_health_max=?,base_armor_min=?,base_armor_max=?,active=1,updated_at=? WHERE id=?`))
.run(name, slot, tier, level, ...health, ...armor, now, id);
@@ -98,12 +100,15 @@ export const actions: Actions = {
for (const brandId of brandIds) await (await database.prepare('INSERT INTO gear_brand_sets(gear_id,brand_set_id) VALUES(?,?)')).run(gearId, brandId);
await (await database.prepare('DELETE FROM gear_talents WHERE gear_id=?')).run(gearId);
for (const talentId of talentIds) await (await database.prepare('INSERT INTO gear_talents(gear_id,talent_id) VALUES(?,?)')).run(gearId, talentId);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'gear.updated' : 'gear.created', entityId: gearId, before, after: { record: await auditRecord('gear', gearId), brandIds, talentIds } });
return { gearSaved: true, gearMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const before = await auditRecord('gear', id);
await (await (await getDatabase()).prepare('UPDATE gear SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'gear.archived', entityId: id, before, after: await auditRecord('gear', id) });
return { gearArchived: true };
}
};
+10 -10
View File
@@ -5,8 +5,9 @@ import { getPagination } from '$lib/server/pagination';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { recordIsacAudit } from '$lib/server/audit';
const filters = ['isac', 'data', 'users'];
const filters = ['isac', 'data', 'users', 'audit'];
export const load: PageServerLoad = async ({ cookies, url }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
@@ -14,7 +15,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const canManageSystemAdmin = operatorPermissions.includes('*');
const database = await getDatabase();
const filter = filters.includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
const permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : '';
const permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : filter === 'audit' ? 'audit.%' : '';
const where = permissionPrefix ? `WHERE EXISTS (
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
WHERE rp.role_id=roles.id AND p.name LIKE ?
@@ -24,7 +25,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'isac.%') THEN 1 ELSE 0 END) AS isac,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'data.%') THEN 1 ELSE 0 END) AS data,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'users.%') THEN 1 ELSE 0 END) AS users
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'users.%') THEN 1 ELSE 0 END) AS users,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'audit.%') THEN 1 ELSE 0 END) AS audit
FROM roles`)).get(),
(await database.prepare(`SELECT COUNT(*) AS total FROM roles ${where}`)).get(...filterArgs),
(await database.prepare(`SELECT id,name,description FROM permissions
@@ -85,13 +87,11 @@ export const actions: Actions = {
for (const permissionId of permissionIds) {
await (await database.prepare('INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)')).run(roleId, permissionId);
}
await (await database.prepare(`INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at)
VALUES(?,?,?,?,?,?,?,?)`)).run(
randomUUID(), operator.id, 'role', roleId, id ? 'role.updated' : 'role.created',
id ? JSON.stringify({ name: existing?.name, description: existing?.description, permissions: beforeLinks.map((row) => row.name) }) : null,
JSON.stringify({ name, description: description || null, permissions: validPermissions.map((row) => row.name) }),
new Date().toISOString()
);
await recordIsacAudit({
actorId: operator.id, category: 'role_management', action: id ? 'role.updated' : 'role.created', entityId: roleId,
before: id ? { name: existing?.name, description: existing?.description, permissions: beforeLinks.map((row) => row.name) } : null,
after: { name, description: description || null, permissions: validPermissions.map((row) => row.name) }
});
return { roleSaved: true, roleMode: id ? 'edit' : 'add', resultToken: randomUUID() };
}
};
+3 -2
View File
@@ -17,11 +17,12 @@
<svelte:head><title>Roles & permissions — ISAC</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ACCESS POLICY REGISTRY</p><h2>ROLES & PERMISSIONS</h2><p>Compose reusable access roles from discrete SHD.build capabilities.</p></div><button class="primary" onclick={() => editing = blank()}>ADD ROLE <span>+</span></button></div>
<KpiFilters active={data.filter} items={[
<KpiFilters className="role-kpis" active={data.filter} items={[
{value:'',label:'TOTAL ROLES',count:data.summary.total,description:'Show all role definitions'},
{value:'isac',label:'ISAC ACCESS',count:data.summary.isac,description:'Can open the console'},
{value:'data',label:'DATA OPERATIONS',count:data.summary.data,description:'Builder-data capabilities'},
{value:'users',label:'USER OPERATIONS',count:data.summary.users,description:'User-management capabilities'}
{value:'users',label:'USER OPERATIONS',count:data.summary.users,description:'User-management capabilities'},
{value:'audit',label:'SYSTEM OVERSIGHT',count:data.summary.audit,description:'Audit-history visibility'}
]}/>
{#if editing}
+10 -5
View File
@@ -5,6 +5,7 @@ import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { getPagination } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
@@ -22,7 +23,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
};
export const actions: Actions = {
save: async ({ request, cookies }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').normalize('NFKC').trim();
const type = String(form.get('type') ?? ''), category = String(form.get('category') ?? ''), calculation = String(form.get('calculationType') ?? ''), unit = String(form.get('unit') ?? '');
@@ -35,13 +36,17 @@ export const actions: Actions = {
for (let index = 0; index < 6; index += 2) if ((values[index] === null) !== (values[index+1] === null) || (values[index] !== null && Number(values[index]) > Number(values[index+1]))) return fail(400, { error: 'Each enabled source needs a valid minimum and maximum.' });
const database = await getDatabase();
if (await (await database.prepare('SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND id <> ?')).get(name, id || 'new')) return fail(409, { error: 'That attribute already exists.' });
const now = new Date().toISOString();
const now = new Date().toISOString(), statId = id || randomUUID();
const before = id ? await auditRecord('stat_attributes', id) : null;
if (id) 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=?,updated_at=? WHERE id=?`)).run(name,type,category,calculation,unit,...values,now,id);
else await (await database.prepare(`INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(randomUUID(),name,type,category,calculation,unit,...values,now,now);
else await (await database.prepare(`INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(statId,name,type,category,calculation,unit,...values,now,now);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'attribute.updated' : 'attribute.created', entityId: statId, before, after: await auditRecord('stat_attributes', statId) });
return { saved: true, editorMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? '');
await (await (await getDatabase()).prepare('UPDATE stat_attributes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(),id); return { archived: true };
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? '');
const before = await auditRecord('stat_attributes', id);
await (await (await getDatabase()).prepare('UPDATE stat_attributes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(),id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'attribute.archived', entityId: id, before, after: await auditRecord('stat_attributes', id) }); return { archived: true };
}
};
+9 -4
View File
@@ -5,6 +5,7 @@ import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { getPagination } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
type SharedEffect = { statId: string; valueI: number; valueII: number };
@@ -107,7 +108,7 @@ const renderDescription = (
export const actions: Actions = {
saveTrigger: async ({ request, cookies }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), description = String(form.get('description') ?? '').trim(), uptime = form.get('supportsUptime') === 'on';
const min = uptime ? Number(form.get('minUptime')) : null, max = uptime ? Number(form.get('maxUptime')) : null;
@@ -115,13 +116,15 @@ export const actions: Actions = {
if (uptime && (!Number.isFinite(min) || !Number.isFinite(max) || Number(min) < 0 || Number(max) > 100 || Number(min) > Number(max))) return fail(400, { triggerError: 'Uptime must be a valid 0–100% range.' });
const db = await getDatabase();
if (await (await db.prepare('SELECT id FROM talent_triggers WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { triggerError: 'That trigger already exists.' });
const now = new Date().toISOString();
const now = new Date().toISOString(), triggerId = id || randomUUID();
const before = id ? await auditRecord('talent_triggers', id) : null;
if (id) await (await db.prepare('UPDATE talent_triggers SET name=?,description=?,supports_uptime=?,min_uptime=?,max_uptime=?,updated_at=? WHERE id=?')).run(name, description, uptime ? 1 : 0, min, max, now, id);
else await (await db.prepare('INSERT INTO talent_triggers(id,name,description,supports_uptime,min_uptime,max_uptime,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)')).run(randomUUID(), name, description, uptime ? 1 : 0, min, max, now, now);
else await (await db.prepare('INSERT INTO talent_triggers(id,name,description,supports_uptime,min_uptime,max_uptime,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)')).run(triggerId, name, description, uptime ? 1 : 0, min, max, now, now);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'talent_trigger.updated' : 'talent_trigger.created', entityId: triggerId, before, after: await auditRecord('talent_triggers', triggerId) });
return { triggerSaved: true, triggerMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
saveTalent: async ({ request, cookies }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? '');
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim();
@@ -141,6 +144,7 @@ export const actions: Actions = {
if (statRows.length !== sharedEffects.length) return fail(400, { talentError: 'One or more selected attributes are unavailable.' });
const units = new Map(statRows.map((stat) => [String(stat.id), stat.unit]));
const talentId = id || randomUUID(), now = new Date().toISOString();
const before = id ? { record: await auditRecord('talents', id), template: await (await db.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(id), tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(id) } : null;
if (id) await (await db.prepare('UPDATE talents SET name=?,category=?,updated_at=? WHERE id=?')).run(name, storedCategory, now, id);
else await (await db.prepare('INSERT INTO talents(id,name,category,created_at,updated_at) VALUES(?,?,?,?,?)')).run(talentId, name, storedCategory, now, now);
if (storedCategory === 'Weapon') {
@@ -171,6 +175,7 @@ export const actions: Actions = {
for (const triggerId of triggerIds) await (await db.prepare('INSERT INTO talent_tier_triggers(talent_tier_id,trigger_id) VALUES(?,?)')).run(tierId, triggerId);
for (const effect of sharedEffects) await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(randomUUID(), tierId, effect.statId, tier === 'I' ? effect.valueI : effect.valueII, now);
}
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'talent.updated' : 'talent.created', entityId: talentId, before, after: { record: await auditRecord('talents', talentId), category, descriptionTemplate, triggerIds, effects: sharedEffects } });
return { talentSaved: true, talentMode: id ? 'edit' : 'add', resultToken: randomUUID() };
}
};
+13 -8
View File
@@ -106,7 +106,7 @@ export const actions: Actions = {
)).run(
randomUUID(),
operator.id,
'user',
'user_moderation',
params.id,
'moderation.avatar_removed',
JSON.stringify({ source: avatar.source, storageKey: avatar.value, byteSize: avatar.byte_size }),
@@ -132,12 +132,16 @@ export const actions: Actions = {
const validRoles = roleIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`)).get(...roleIds))?.count) : 0;
const validPermissions = permissionIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`)).get(...permissionIds))?.count) : 0;
if (validRoles !== roleIds.length || validPermissions !== permissionIds.length) return fail(400, { accessError: 'One or more access selections are invalid.' });
const before = {
roleIds: (await (await database.prepare('SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id')).all(params.id)).map((row) => row.role_id),
permissionIds: (await (await database.prepare('SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id')).all(params.id)).map((row) => row.permission_id)
};
await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(params.id);
await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(params.id);
for (const roleId of roleIds) await (await database.prepare('INSERT INTO user_roles(user_id,role_id) VALUES(?,?)')).run(params.id, roleId);
for (const permissionId of permissionIds) await (await database.prepare('INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)')).run(params.id, permissionId);
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,after_json,created_at) VALUES(?,?,?,?,?,?,?)'))
.run(randomUUID(), operator.id, 'user', params.id, 'access.updated', JSON.stringify({ roleIds, permissionIds }), new Date().toISOString());
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)'))
.run(randomUUID(), operator.id, 'user_access', params.id, 'access.updated', JSON.stringify(before), JSON.stringify({ roleIds, permissionIds }), new Date().toISOString());
return { accessSaved: true };
},
moderate: async ({ cookies, request, params }) => {
@@ -156,12 +160,13 @@ export const actions: Actions = {
if ((target.role === 'admin' || targetAdminRole) && !(await getPermissions(operator)).includes('*')) {
return fail(403, { moderationError: 'Only an administrator can moderate another administrator.' });
}
const before = await (await database.prepare('SELECT blocked_at,blocked_by,banned_until,moderation_reason FROM user_status WHERE user_id=?')).get(params.id);
await (await database.prepare(`INSERT INTO user_status(user_id,blocked_at,blocked_by,banned_until,moderation_reason,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET blocked_at=excluded.blocked_at,blocked_by=excluded.blocked_by,banned_until=excluded.banned_until,moderation_reason=excluded.moderation_reason,updated_at=excluded.updated_at`))
.run(params.id, state === 'blocked' ? now : null, state === 'active' ? null : operator.id, state === 'banned' ? bannedUntil!.toISOString() : null, state === 'active' ? null : reason, now);
if (state !== 'active') await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(params.id);
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,after_json,created_at) VALUES(?,?,?,?,?,?,?)'))
.run(randomUUID(), operator.id, 'user', params.id, `moderation.${state}`, JSON.stringify({ reason, bannedUntil: bannedUntil?.toISOString() }), now);
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)'))
.run(randomUUID(), operator.id, 'user_moderation', params.id, `moderation.${state}`, JSON.stringify(before ?? null), JSON.stringify({ reason, bannedUntil: bannedUntil?.toISOString() }), now);
return { moderationSaved: true };
},
deleteAccount: async ({ cookies, request, params }) => {
@@ -194,7 +199,7 @@ export const actions: Actions = {
await (await database.prepare('UPDATE users SET discord_id=NULL WHERE id=?')).run(params.id);
await (await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')).run(params.id);
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)'))
.run(randomUUID(), operator.id, 'user', params.id, 'identity.discord_unlinked', JSON.stringify({ discordId: target.discord_id }), now);
.run(randomUUID(), operator.id, 'identity', params.id, 'identity.discord_unlinked', JSON.stringify({ discordId: target.discord_id }), now);
return { identityUnlinked: true };
},
removeEmail: async ({ cookies, params }) => {
@@ -208,7 +213,7 @@ export const actions: Actions = {
await (await database.prepare('DELETE FROM user_emails WHERE user_id=?')).run(params.id);
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(params.id);
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)'))
.run(randomUUID(), operator.id, 'user', params.id, 'identity.email_removed', JSON.stringify({ email: email.email }), new Date().toISOString());
.run(randomUUID(), operator.id, 'identity', params.id, 'identity.email_removed', JSON.stringify({ email: email.email }), new Date().toISOString());
return { emailRemoved: true };
},
removePasskey: async ({ cookies, request, params }) => {
@@ -222,7 +227,7 @@ export const actions: Actions = {
}
await (await database.prepare('DELETE FROM passkeys WHERE id=? AND user_id=?')).run(passkeyId, params.id);
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)'))
.run(randomUUID(), operator.id, 'user', params.id, 'identity.passkey_removed', JSON.stringify({ id: passkey.id, name: passkey.name }), new Date().toISOString());
.run(randomUUID(), operator.id, 'identity', params.id, 'identity.passkey_removed', JSON.stringify({ id: passkey.id, name: passkey.name }), new Date().toISOString());
return { passkeyRemoved: true };
}
};
+13 -2
View File
@@ -5,6 +5,7 @@ import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { getPagination } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
@@ -75,7 +76,7 @@ function readRange(form: FormData, tier: number, range: (typeof rangeNames)[numb
export const actions: Actions = {
save: async ({ request, cookies }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
@@ -178,6 +179,13 @@ export const actions: Actions = {
const weaponId = id || randomUUID();
const now = new Date().toISOString();
const before = id ? {
record: await auditRecord('weapons', id),
tiers: await (await database.prepare('SELECT * FROM weapon_tier_ranges WHERE weapon_id=? ORDER BY tier')).all(id),
talents: await (await database.prepare('SELECT * FROM weapon_talent_options WHERE weapon_id=? ORDER BY talent_slot')).all(id),
augmentEffects: await (await database.prepare('SELECT * FROM weapon_augmentation_effects WHERE weapon_id=? ORDER BY sort_order')).all(id),
baseBonuses: await (await database.prepare('SELECT * FROM weapon_base_attribute_bonuses WHERE weapon_id=? ORDER BY sort_order')).all(id)
} : null;
if (id) {
const exists = await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id);
if (!exists) return fail(404, { weaponError: 'Weapon not found.' });
@@ -209,12 +217,15 @@ export const actions: Actions = {
await (await database.prepare('INSERT INTO weapon_base_attribute_bonuses(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)'))
.run(randomUUID(), weaponId, effect.statId, effect.value, effect.order);
}
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, talent1Ids, talent2Ids, augmentEffects, baseBonuses } });
return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const id = String((await request.formData()).get('id') ?? '');
const before = await auditRecord('weapons', id);
await (await (await getDatabase()).prepare('UPDATE weapons SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'weapon.archived', entityId: id, before, after: await auditRecord('weapons', id) });
return { weaponArchived: true };
}
};