diff --git a/AGENTS.md b/AGENTS.md index 263119a..95934a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,12 @@ Follow these rules: 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. +11. Audit reversion is a separately authorized compensating action, never a + history rewrite. Require `audit.revert`, an explicit confirmation + interaction, current-state comparison against the original after-snapshot, + relationship validation, and a new `audit.reverted` event referencing the + original event. Block stale, already-reverted, unsupported, destructive + identity, and incomplete-snapshot events rather than guessing. ## Database preservation diff --git a/database/schema.sql b/database/schema.sql index ddd9d8c..53b4f39 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -123,6 +123,7 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users- 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 permissions(id,name,description) VALUES('permission-audit-revert','audit.revert','Attempt confirmed compensating reversals of supported ISAC audit events'); 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'); diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index ca95e96..c837778 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -222,6 +222,14 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps 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. +- Supported audit events may expose a permissioned compensating-action control. + Reversion uses a danger-styled `ATTEMPT REVERT` entry action followed by a + modal that explains the original action, operator, target, and conflict + checks. The orange Confirm action is the only submission path. ISAC compares + current state with the original event’s after-snapshot before restoring the + before-snapshot; newer work blocks the operation. Successful reversions append + a new `audit.reverted` event and never alter the original history. `audit.view` + remains read-only; `audit.revert` is independently assigned. - 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/docs/architecture.md b/docs/architecture.md index 5382c36..fdc1b45 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,3 +83,33 @@ The restore operation refuses to import into any database containing records. This guard prevents accidental merging or overwriting of contributor data. Stop the application before backup or restore so the embedded database file is not locked by another process. + +# ISAC audit and compensating reversals + +Every successful ISAC mutation appends an event to `audit_log` through the +shared server audit utility. Events identify the operator, operational category, +specific action, target entity, timestamp, and useful before/after evidence. +The audit registry is read-only and requires `audit.view`; the seeded Auditor +role grants that permission without granting mutation capabilities. + +Audit history is immutable. “Revert” is implemented as a new compensating +mutation rather than deletion or modification of the original event: + +1. The operator must independently hold `audit.revert`. +2. The original action must be explicitly supported and contain a usable + before-snapshot. +3. The UI requires a second, explicit confirmation. +4. The server reloads the target and compares it with the original + after-snapshot, ignoring bookkeeping timestamps. A mismatch means newer work + exists and blocks automatic compensation. +5. Referenced roles, permissions, and relationships are revalidated before + restoration. Restoring administrator authority additionally requires full + administrative access. +6. The server restores the previous state and appends an `audit.reverted` event + whose target is the original audit-event ID. +7. A successfully reverted event cannot be reverted again. + +Unsupported destructive identity operations, account deletion, incomplete +snapshots, stale targets, and events whose dependencies no longer exist are +marked non-reversible or rejected. Recovery for those cases requires a +specialized workflow rather than inferred database writes. diff --git a/src/app.css b/src/app.css index 03129cf..9f47810 100644 --- a/src/app.css +++ b/src/app.css @@ -106,4 +106,5 @@ .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-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}} +.audit-revert-bar{min-height:48px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border-top:1px solid #4c302d;background:#1c1111}.audit-revert-bar>span{color:#a87870;font-size:7px;letter-spacing:.8px}.audit-revert-bar button{height:32px}.audit-revert-bar.completed{border-color:#36563f;background:#101713}.audit-revert-bar.completed>span{color:var(--shd-success)}.audit-revert-modal>p{color:#969d9f;font:13px/1.6 Georgia,serif}.audit-revert-modal code{color:#d7d9d5;font:11px Consolas,monospace}.audit-revert-summary{display:grid;grid-template-columns:1fr 1fr 1.4fr;gap:7px;margin:14px 0}.audit-revert-summary>div{min-width:0;display:grid;gap:5px;padding:9px;border:1px solid #303536;background:#0d1011}.audit-revert-summary span{color:#626a6c;font-size:6px;letter-spacing:.8px}.audit-revert-summary b{overflow:hidden;text-overflow:ellipsis;color:#d7d9d5;font-size:8px;white-space:nowrap}@media(max-width:600px){.audit-revert-bar{align-items:stretch;flex-direction:column}.audit-revert-summary{grid-template-columns:1fr}} diff --git a/src/routes/admin/audit/+page.server.ts b/src/routes/admin/audit/+page.server.ts index 95bd5d8..4421fcd 100644 --- a/src/routes/admin/audit/+page.server.ts +++ b/src/routes/admin/audit/+page.server.ts @@ -1,11 +1,48 @@ -import { requirePermission } from '$lib/server/auth/authorization'; +import { getPermissions, requirePermission } from '$lib/server/auth/authorization'; import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; import { getPagination } from '$lib/server/pagination'; -import type { PageServerLoad } from './$types'; +import { recordIsacAudit } from '$lib/server/audit'; +import { fail } from '@sveltejs/kit'; +import { randomUUID } from 'node:crypto'; +import type { Actions, PageServerLoad } from './$types'; + +const reversibleActions = new Set([ + 'attribute.updated', 'attribute.archived', + 'brand_set.archived', 'gear.archived', 'weapon.archived', + 'role.updated', 'access.updated' +]); + +const parseSnapshot = (value: unknown): Record | null => { + if (typeof value !== 'string' || !value) return null; + try { const parsed = JSON.parse(value); return parsed && typeof parsed === 'object' ? parsed : null; } catch { return null; } +}; + +const normalize = (value: unknown): unknown => { + if (Array.isArray(value)) { + const normalized = value.map(normalize); + return normalized.every((item) => ['string', 'number', 'boolean'].includes(typeof item)) + ? normalized.sort((left, right) => String(left).localeCompare(String(right))) + : normalized; + } + if (!value || typeof value !== 'object') return value; + return Object.fromEntries(Object.entries(value as Record) + .filter(([key]) => !['created_at', 'updated_at'].includes(key)) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, normalize(item)])); +}; +const comparable = (value: unknown): string => JSON.stringify(normalize(value)); + +const sameRecord = (current: Record | null, expected: Record | null) => { + if (!current || !expected) return false; + return Object.entries(expected).every(([key, value]) => + !['created_at', 'updated_at'].includes(key) && comparable(current[key]) === comparable(value) + ); +}; export const load: PageServerLoad = async ({ cookies, url }) => { - await requirePermission(await getSessionUser(cookies), 'audit.view'); + const operator = await requirePermission(await getSessionUser(cookies), 'audit.view'); + const canRevert = (await getPermissions(operator)).includes('*') || (await getPermissions(operator)).includes('audit.revert'); const database = await getDatabase(); const query = url.searchParams.get('q')?.trim().slice(0, 120) ?? ''; const category = url.searchParams.get('category')?.trim() ?? ''; @@ -43,5 +80,87 @@ export const load: PageServerLoad = async ({ cookies, url }) => { 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 } }; + const revertedIds = entries.length ? await (await database.prepare(`SELECT entity_id FROM audit_log + WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id IN (${entries.map(() => '?').join(',')})`)).all(...entries.map((entry) => entry.id)) : []; + const reverted = new Set(revertedIds.map((entry) => String(entry.entity_id))); + return { + entries: entries.map((entry) => ({ ...entry, reversible: canRevert && reversibleActions.has(String(entry.action)) && Boolean(entry.before_json) && !reverted.has(String(entry.id)), reverted: reverted.has(String(entry.id)) })), + categories, actions, summary, pagination, filters: { query, category, action }, canRevert + }; +}; + +export const actions: Actions = { + revert: async ({ cookies, request }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'audit.revert'); + const operatorPermissions = await getPermissions(operator); + const isFullAdmin = operatorPermissions.includes('*'); + const eventId = String((await request.formData()).get('eventId') ?? ''); + const database = await getDatabase(); + const event = await (await database.prepare('SELECT * FROM audit_log WHERE id=?')).get(eventId); + if (!event) return fail(404, { revertError: 'Audit event not found.' }); + if (!reversibleActions.has(String(event.action)) || !event.before_json) return fail(400, { revertError: 'This event does not contain a supported reversible change.' }); + if (await (await database.prepare("SELECT id FROM audit_log WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id=?")).get(eventId)) { + return fail(409, { revertError: 'This audit event has already been reverted.' }); + } + const before = parseSnapshot(event.before_json), after = parseSnapshot(event.after_json); + if (!before || !after) return fail(400, { revertError: 'The stored snapshots cannot be safely interpreted.' }); + const action = String(event.action), targetId = String(event.entity_id); + let current: Record | null = null; + let restored: Record | null = null; + try { + if (action.startsWith('attribute.')) { + current = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null; + if (!sameRecord(current, after)) return fail(409, { revertError: 'The attribute changed after this event. Reversion was blocked to preserve newer work.' }); + await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?, + weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`)) + .run(before.name,before.type,before.category,before.calculation_type,before.unit,before.gear_min,before.gear_max,before.weapon_min,before.weapon_max,before.skill_chip_min,before.skill_chip_max,before.cap_value,before.active,new Date().toISOString(),targetId); + restored = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null; + } else if (action.endsWith('.archived')) { + const table = action.startsWith('brand_set.') ? 'brand_sets' : action.startsWith('gear.') ? 'gear' : action.startsWith('weapon.') ? 'weapons' : ''; + if (!table) return fail(400, { revertError: 'Unsupported archive category.' }); + current = await (await database.prepare(`SELECT * FROM ${table} WHERE id=?`)).get(targetId) ?? null; + if (!sameRecord(current, after)) return fail(409, { revertError: 'The record changed after it was archived. Reversion was blocked.' }); + await (await database.prepare(`UPDATE ${table} SET active=?,updated_at=? WHERE id=?`)).run(before.active, new Date().toISOString(), targetId); + restored = await (await database.prepare(`SELECT * FROM ${table} WHERE id=?`)).get(targetId) ?? null; + } else if (action === 'role.updated') { + const currentRole = await (await database.prepare('SELECT name,description FROM roles WHERE id=?')).get(targetId); + const currentPermissions = await (await database.prepare(`SELECT permissions.name FROM role_permissions JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_id=? ORDER BY permissions.name`)).all(targetId); + current = { ...currentRole, permissions: currentPermissions.map((row) => row.name) }; + if (comparable(current) !== comparable(after)) return fail(409, { revertError: 'The role changed after this event. Reversion was blocked.' }); + const permissionNames = Array.isArray(before.permissions) ? before.permissions.map(String) : []; + if (permissionNames.includes('system.admin') && !isFullAdmin) return fail(403, { revertError: 'Only a full administrator can restore unrestricted system access.' }); + const permissionRows = permissionNames.length ? await (await database.prepare(`SELECT id,name FROM permissions WHERE name IN (${permissionNames.map(() => '?').join(',')})`)).all(...permissionNames) : []; + if (permissionRows.length !== permissionNames.length) return fail(409, { revertError: 'A previous permission no longer exists.' }); + await (await database.prepare('UPDATE roles SET name=?,description=? WHERE id=?')).run(before.name, before.description ?? null, targetId); + await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(targetId); + for (const permission of permissionRows) await (await database.prepare('INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)')).run(targetId, permission.id); + restored = { name: before.name, description: before.description ?? null, permissions: permissionNames }; + } else if (action === 'access.updated') { + const currentRoles = await (await database.prepare('SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id')).all(targetId); + const currentPermissions = await (await database.prepare('SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id')).all(targetId); + current = { roleIds: currentRoles.map((row) => row.role_id), permissionIds: currentPermissions.map((row) => row.permission_id) }; + if (comparable(current) !== comparable(after)) return fail(409, { revertError: 'The user’s access changed after this event. Reversion was blocked.' }); + const roleIds = Array.isArray(before.roleIds) ? before.roleIds.map(String) : []; + const permissionIds = Array.isArray(before.permissionIds) ? before.permissionIds.map(String) : []; + const restoresAdminRole = roleIds.includes('role-admin'); + const restoresSystemPermission = permissionIds.includes('permission-system-admin'); + if ((restoresAdminRole || restoresSystemPermission) && !isFullAdmin) return fail(403, { revertError: 'Only a full administrator can restore administrator access.' }); + const roleCount = roleIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`)).get(...roleIds))?.count) : 0; + const permissionCount = permissionIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`)).get(...permissionIds))?.count) : 0; + if (roleCount !== roleIds.length || permissionCount !== permissionIds.length) return fail(409, { revertError: 'A previous role or permission no longer exists.' }); + await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(targetId); + await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(targetId); + for (const roleId of roleIds) await (await database.prepare('INSERT INTO user_roles(user_id,role_id) VALUES(?,?)')).run(targetId, roleId); + for (const permissionId of permissionIds) await (await database.prepare('INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)')).run(targetId, permissionId); + restored = { roleIds, permissionIds }; + } else { + return fail(400, { revertError: 'This change requires a specialized recovery workflow that is not available yet.' }); + } + } catch (error) { + if (error && typeof error === 'object' && 'status' in error) throw error; + return fail(409, { revertError: 'The previous state could not be restored because related data has changed.' }); + } + await recordIsacAudit({ actorId: operator.id, category: 'audit_control', action: 'audit.reverted', entityId: eventId, before: current, after: { originalEventId: eventId, originalAction: action, targetId, restored } }); + return { reverted: true, resultToken: randomUUID() }; + } }; diff --git a/src/routes/admin/audit/+page.svelte b/src/routes/admin/audit/+page.svelte index 04a6faf..a97dd1b 100644 --- a/src/routes/admin/audit/+page.svelte +++ b/src/routes/admin/audit/+page.svelte @@ -1,12 +1,21 @@ Audit history — ISAC @@ -30,6 +39,20 @@
{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)}
+ {#if entry.reversible}
SUPPORTED COMPENSATING ACTION · CURRENT STATE WILL BE VERIFIED FIRST
+ {:else if entry.reverted}
THIS EVENT HAS ALREADY BEEN REVERTED
{/if}
{:else}
NO AUDIT EVENTS FOUNDAdjust the category, action, or search value.
{/each} + +{#if confirming} + +{/if}