From 6be31de986d91233e727864b9d5d81fbd274a3ab Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 03:48:22 -0400 Subject: [PATCH] Add comprehensive ISAC audit logging --- AGENTS.md | 40 ++++++++++++++++++ database/schema.sql | 4 ++ docs/DESIGN_SYSTEM.md | 6 +++ src/app.css | 3 +- src/lib/components/KpiFilters.svelte | 6 +-- src/lib/server/audit.ts | 35 +++++++++++++++ src/lib/server/auth/account-deletion.ts | 2 +- src/routes/admin/+layout.svelte | 6 +++ src/routes/admin/+page.server.ts | 1 + src/routes/admin/audit/+page.server.ts | 47 +++++++++++++++++++++ src/routes/admin/audit/+page.svelte | 35 +++++++++++++++ src/routes/admin/brands/+page.server.ts | 9 +++- src/routes/admin/gear/+page.server.ts | 9 +++- src/routes/admin/roles/+page.server.ts | 20 ++++----- src/routes/admin/roles/+page.svelte | 5 ++- src/routes/admin/stats/+page.server.ts | 15 ++++--- src/routes/admin/talents/+page.server.ts | 13 ++++-- src/routes/admin/users/[id]/+page.server.ts | 21 +++++---- src/routes/admin/weapons/+page.server.ts | 15 ++++++- 19 files changed, 252 insertions(+), 40 deletions(-) create mode 100644 src/lib/server/audit.ts create mode 100644 src/routes/admin/audit/+page.server.ts create mode 100644 src/routes/admin/audit/+page.svelte diff --git a/AGENTS.md b/AGENTS.md index ba8a15e..263119a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/database/schema.sql b/database/schema.sql index bf74a54..ddd9d8c 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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, diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 7e933f8..ca95e96 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -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, diff --git a/src/app.css b/src/app.css index a3de51c..03129cf 100644 --- a/src/app.css +++ b/src/app.css @@ -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}} diff --git a/src/lib/components/KpiFilters.svelte b/src/lib/components/KpiFilters.svelte index f54917c..7266937 100644 --- a/src/lib/components/KpiFilters.svelte +++ b/src/lib/components/KpiFilters.svelte @@ -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}` : ''}`; diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts new file mode 100644 index 0000000..370cea6 --- /dev/null +++ b/src/lib/server/audit.ts @@ -0,0 +1,35 @@ +import { getDatabase } from '$lib/server/database'; +import { randomUUID } from 'node:crypto'; + +type AuditValue = Record | 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 | 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; +} diff --git a/src/lib/server/auth/account-deletion.ts b/src/lib/server/auth/account-deletion.ts index 4fad2e1..7f07392 100644 --- a/src/lib/server/auth/account-deletion.ts +++ b/src/lib/server/auth/account-deletion.ts @@ -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 }), diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index b38f502..0a2b275 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -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)));
@@ -33,6 +35,10 @@ {#each dataSections as section}{section.code}{section.label}›{/each} {/if} + {#if visibleOversightSections.length} + + + {/if}
{@render children()}
diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts index 3516444..aa59741 100644 --- a/src/routes/admin/+page.server.ts +++ b/src/routes/admin/+page.server.ts @@ -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'); }; diff --git a/src/routes/admin/audit/+page.server.ts b/src/routes/admin/audit/+page.server.ts new file mode 100644 index 0000000..95bd5d8 --- /dev/null +++ b/src/routes/admin/audit/+page.server.ts @@ -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 } }; +}; diff --git a/src/routes/admin/audit/+page.svelte b/src/routes/admin/audit/+page.svelte new file mode 100644 index 0000000..04a6faf --- /dev/null +++ b/src/routes/admin/audit/+page.svelte @@ -0,0 +1,35 @@ + + +Audit history — ISAC +

SYSTEM OVERSIGHT

AUDIT HISTORY

Immutable operator activity, target records, and before/after change evidence.

+ +
+ + + + +
+ +
+
EVENTOPERATORTARGETTIME
+ {#each data.entries as entry} +
+
{String(entry.action).toUpperCase()}{String(entry.entity_type).replaceAll('_',' ').toUpperCase()}
{entry.actor_name}{entry.actor_login ?? entry.actor_id}
{entry.entity_id}EVENT {String(entry.id).slice(0,8).toUpperCase()}
{date(entry.created_at)}DETAILS +
+
BEFORE
{pretty(entry.before_json)}
AFTER
{pretty(entry.after_json)}
+
+ {:else}
NO AUDIT EVENTS FOUNDAdjust the category, action, or search value.
{/each} +
diff --git a/src/routes/admin/brands/+page.server.ts b/src/routes/admin/brands/+page.server.ts index 886fa45..caed44b 100644 --- a/src/routes/admin/brands/+page.server.ts +++ b/src/routes/admin/brands/+page.server.ts @@ -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 }; } }; diff --git a/src/routes/admin/gear/+page.server.ts b/src/routes/admin/gear/+page.server.ts index b79adfc..89aaaa3 100644 --- a/src/routes/admin/gear/+page.server.ts +++ b/src/routes/admin/gear/+page.server.ts @@ -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 }; } }; diff --git a/src/routes/admin/roles/+page.server.ts b/src/routes/admin/roles/+page.server.ts index 3fbb11d..1b2ccad 100644 --- a/src/routes/admin/roles/+page.server.ts +++ b/src/routes/admin/roles/+page.server.ts @@ -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() }; } }; diff --git a/src/routes/admin/roles/+page.svelte b/src/routes/admin/roles/+page.svelte index 1d87e91..9957a84 100644 --- a/src/routes/admin/roles/+page.svelte +++ b/src/routes/admin/roles/+page.svelte @@ -17,11 +17,12 @@ Roles & permissions — ISAC

ACCESS POLICY REGISTRY

ROLES & PERMISSIONS

Compose reusable access roles from discrete SHD.build capabilities.

- {#if editing} diff --git a/src/routes/admin/stats/+page.server.ts b/src/routes/admin/stats/+page.server.ts index cf68a97..6db3a54 100644 --- a/src/routes/admin/stats/+page.server.ts +++ b/src/routes/admin/stats/+page.server.ts @@ -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 }; } }; diff --git a/src/routes/admin/talents/+page.server.ts b/src/routes/admin/talents/+page.server.ts index a1bde54..2ad5036 100644 --- a/src/routes/admin/talents/+page.server.ts +++ b/src/routes/admin/talents/+page.server.ts @@ -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() }; } }; diff --git a/src/routes/admin/users/[id]/+page.server.ts b/src/routes/admin/users/[id]/+page.server.ts index 83debfc..222ed5b 100644 --- a/src/routes/admin/users/[id]/+page.server.ts +++ b/src/routes/admin/users/[id]/+page.server.ts @@ -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 }; } }; diff --git a/src/routes/admin/weapons/+page.server.ts b/src/routes/admin/weapons/+page.server.ts index 96b2480..7b649fa 100644 --- a/src/routes/admin/weapons/+page.server.ts +++ b/src/routes/admin/weapons/+page.server.ts @@ -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 }; } };