Add comprehensive ISAC audit logging
This commit is contained in:
@@ -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.
|
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
|
## Database preservation
|
||||||
|
|
||||||
Development data is valuable and may be entered by non-technical contributors.
|
Development data is valuable and may be entered by non-technical contributors.
|
||||||
|
|||||||
@@ -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-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-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-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-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-data-editor','permission-data-manage');
|
||||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-operator','permission-isac-access');
|
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-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-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 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 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 (
|
CREATE TABLE IF NOT EXISTS discord_profiles (
|
||||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -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
|
must never be able to grant `system.admin`; both option visibility and
|
||||||
server-side validation enforce this boundary. Role mutations are recorded in
|
server-side validation enforce this boundary. Role mutations are recorded in
|
||||||
the audit log with their previous and resulting permission sets.
|
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,
|
- Account deletion uses a collapsed danger zone, explicit identity confirmation,
|
||||||
and plain-language retention guidance. Soft-deleted accounts retain authored
|
and plain-language retention guidance. Soft-deleted accounts retain authored
|
||||||
content under a deleted-agent identity while releasing credentials, usernames,
|
content under a deleted-agent identity while releasing credentials, usernames,
|
||||||
|
|||||||
+2
-1
@@ -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}}
|
@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%}}
|
.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)}
|
.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}}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
|
||||||
type Item = { value: string; label: string; count: string | number; description: string };
|
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) {
|
function href(value: string) {
|
||||||
const params = new URLSearchParams(page.url.searchParams);
|
const params = new URLSearchParams(page.url.searchParams);
|
||||||
if (value) params.set('filter', value);
|
if (value) params.set(parameter, value);
|
||||||
else params.delete('filter');
|
else params.delete(parameter);
|
||||||
params.delete('page');
|
params.delete('page');
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
return `${page.url.pathname}${query ? `?${query}` : ''}`;
|
return `${page.url.pathname}${query ? `?${query}` : ''}`;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ export async function softDeleteAccount(
|
|||||||
)).run(
|
)).run(
|
||||||
randomUUID(),
|
randomUUID(),
|
||||||
deletedBy,
|
deletedBy,
|
||||||
'user',
|
'user_access',
|
||||||
userId,
|
userId,
|
||||||
'account.soft_deleted',
|
'account.soft_deleted',
|
||||||
purgeSecurityData ? JSON.stringify({ purgedAtOwnerRequest: true }) : JSON.stringify({ username: user.username, displayName: user.display_name }),
|
purgeSecurityData ? JSON.stringify({ purgedAtOwnerRequest: true }) : JSON.stringify({ username: user.username, displayName: user.display_name }),
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
{ href: '/admin/roles', label: 'Roles & permissions', code: 'ROLE', permission: 'users.roles' }
|
{ href: '/admin/roles', label: 'Roles & permissions', code: 'ROLE', permission: 'users.roles' }
|
||||||
];
|
];
|
||||||
const visibleUserSections = $derived(userSections.filter((section) => can(section.permission)));
|
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>
|
</script>
|
||||||
|
|
||||||
<main class="admin-page">
|
<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}
|
{#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>
|
</nav>
|
||||||
{/if}
|
{/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>
|
<div class="sidebar-foot"><span>ISAC NODE</span><strong>NYC-01</strong><small>Access policy synchronized</small></div>
|
||||||
</aside>
|
</aside>
|
||||||
<section class="admin-content">{@render children()}</section>
|
<section class="admin-content">{@render children()}</section>
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
|||||||
if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/admin/users');
|
if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/admin/users');
|
||||||
if (permissions.includes('users.roles')) redirect(303, '/admin/roles');
|
if (permissions.includes('users.roles')) redirect(303, '/admin/roles');
|
||||||
if (permissions.includes('data.manage')) redirect(303, '/admin/stats');
|
if (permissions.includes('data.manage')) redirect(303, '/admin/stats');
|
||||||
|
if (permissions.includes('audit.view')) redirect(303, '/admin/audit');
|
||||||
redirect(303, '/account');
|
redirect(303, '/account');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 } };
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -5,6 +5,7 @@ import { fail } from '@sveltejs/kit';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { getPagination } from '$lib/server/pagination';
|
import { getPagination } from '$lib/server/pagination';
|
||||||
|
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||||
|
|
||||||
const thresholds = [2, 3, 4] as const;
|
const thresholds = [2, 3, 4] as const;
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
save: async ({ request, cookies }) => {
|
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 form = await request.formData();
|
||||||
const id = String(form.get('id') ?? '');
|
const id = String(form.get('id') ?? '');
|
||||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
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);
|
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.' });
|
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 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);
|
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);
|
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) {
|
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`
|
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);
|
)).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() };
|
return { brandSaved: true, brandMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||||
},
|
},
|
||||||
archive: async ({ request, cookies }) => {
|
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 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 (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 };
|
return { brandArchived: true };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { fail } from '@sveltejs/kit';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { getPagination } from '$lib/server/pagination';
|
import { getPagination } from '$lib/server/pagination';
|
||||||
|
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||||
|
|
||||||
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
|
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
|
||||||
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
|
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
|
||||||
@@ -51,7 +52,7 @@ const readRange = (form: FormData, prefix: string) => {
|
|||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
save: async ({ request, cookies }) => {
|
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 form = await request.formData();
|
||||||
const id = String(form.get('id') ?? '');
|
const id = String(form.get('id') ?? '');
|
||||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
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.` });
|
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 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) {
|
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=?`))
|
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);
|
.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);
|
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);
|
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);
|
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() };
|
return { gearSaved: true, gearMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||||
},
|
},
|
||||||
archive: async ({ request, cookies }) => {
|
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 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 (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 };
|
return { gearArchived: true };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { getPagination } from '$lib/server/pagination';
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
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 }) => {
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
|
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 canManageSystemAdmin = operatorPermissions.includes('*');
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const filter = filters.includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
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 (
|
const where = permissionPrefix ? `WHERE EXISTS (
|
||||||
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
|
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 ?
|
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,
|
(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 '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 '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(),
|
FROM roles`)).get(),
|
||||||
(await database.prepare(`SELECT COUNT(*) AS total FROM roles ${where}`)).get(...filterArgs),
|
(await database.prepare(`SELECT COUNT(*) AS total FROM roles ${where}`)).get(...filterArgs),
|
||||||
(await database.prepare(`SELECT id,name,description FROM permissions
|
(await database.prepare(`SELECT id,name,description FROM permissions
|
||||||
@@ -85,13 +87,11 @@ export const actions: Actions = {
|
|||||||
for (const permissionId of permissionIds) {
|
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 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)
|
await recordIsacAudit({
|
||||||
VALUES(?,?,?,?,?,?,?,?)`)).run(
|
actorId: operator.id, category: 'role_management', action: id ? 'role.updated' : 'role.created', entityId: roleId,
|
||||||
randomUUID(), operator.id, 'role', roleId, id ? 'role.updated' : 'role.created',
|
before: id ? { name: existing?.name, description: existing?.description, permissions: beforeLinks.map((row) => row.name) } : null,
|
||||||
id ? JSON.stringify({ name: existing?.name, description: existing?.description, permissions: beforeLinks.map((row) => row.name) }) : null,
|
after: { name, description: description || null, permissions: validPermissions.map((row) => row.name) }
|
||||||
JSON.stringify({ name, description: description || null, permissions: validPermissions.map((row) => row.name) }),
|
});
|
||||||
new Date().toISOString()
|
|
||||||
);
|
|
||||||
return { roleSaved: true, roleMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
return { roleSaved: true, roleMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,11 +17,12 @@
|
|||||||
|
|
||||||
<svelte:head><title>Roles & permissions — ISAC</title></svelte:head>
|
<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>
|
<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:'',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:'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:'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}
|
{#if editing}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { fail } from '@sveltejs/kit';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { getPagination } from '$lib/server/pagination';
|
import { getPagination } from '$lib/server/pagination';
|
||||||
|
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
await requirePermission(await getSessionUser(cookies), 'data.manage');
|
await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||||
@@ -22,7 +23,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
};
|
};
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
save: async ({ request, cookies }) => {
|
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 form = await request.formData();
|
||||||
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
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') ?? '');
|
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.' });
|
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();
|
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.' });
|
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);
|
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() };
|
return { saved: true, editorMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||||
},
|
},
|
||||||
archive: async ({ request, cookies }) => {
|
archive: async ({ request, cookies }) => {
|
||||||
await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? '');
|
const operator = 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 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 };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { fail } from '@sveltejs/kit';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { getPagination } from '$lib/server/pagination';
|
import { getPagination } from '$lib/server/pagination';
|
||||||
|
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||||
|
|
||||||
type SharedEffect = { statId: string; valueI: number; valueII: number };
|
type SharedEffect = { statId: string; valueI: number; valueII: number };
|
||||||
|
|
||||||
@@ -107,7 +108,7 @@ const renderDescription = (
|
|||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
saveTrigger: async ({ request, cookies }) => {
|
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 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 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;
|
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.' });
|
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();
|
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.' });
|
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);
|
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() };
|
return { triggerSaved: true, triggerMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||||
},
|
},
|
||||||
saveTalent: async ({ request, cookies }) => {
|
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 form = await request.formData();
|
||||||
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? '');
|
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? '');
|
||||||
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim();
|
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.' });
|
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 units = new Map(statRows.map((stat) => [String(stat.id), stat.unit]));
|
||||||
const talentId = id || randomUUID(), now = new Date().toISOString();
|
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);
|
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);
|
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') {
|
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 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);
|
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() };
|
return { talentSaved: true, talentMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export const actions: Actions = {
|
|||||||
)).run(
|
)).run(
|
||||||
randomUUID(),
|
randomUUID(),
|
||||||
operator.id,
|
operator.id,
|
||||||
'user',
|
'user_moderation',
|
||||||
params.id,
|
params.id,
|
||||||
'moderation.avatar_removed',
|
'moderation.avatar_removed',
|
||||||
JSON.stringify({ source: avatar.source, storageKey: avatar.value, byteSize: avatar.byte_size }),
|
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 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;
|
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.' });
|
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_roles WHERE user_id=?')).run(params.id);
|
||||||
await (await database.prepare('DELETE FROM user_permissions 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 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);
|
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(?,?,?,?,?,?,?)'))
|
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', params.id, 'access.updated', JSON.stringify({ roleIds, permissionIds }), new Date().toISOString());
|
.run(randomUUID(), operator.id, 'user_access', params.id, 'access.updated', JSON.stringify(before), JSON.stringify({ roleIds, permissionIds }), new Date().toISOString());
|
||||||
return { accessSaved: true };
|
return { accessSaved: true };
|
||||||
},
|
},
|
||||||
moderate: async ({ cookies, request, params }) => {
|
moderate: async ({ cookies, request, params }) => {
|
||||||
@@ -156,12 +160,13 @@ export const actions: Actions = {
|
|||||||
if ((target.role === 'admin' || targetAdminRole) && !(await getPermissions(operator)).includes('*')) {
|
if ((target.role === 'admin' || targetAdminRole) && !(await getPermissions(operator)).includes('*')) {
|
||||||
return fail(403, { moderationError: 'Only an administrator can moderate another administrator.' });
|
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(?,?,?,?,?,?)
|
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`))
|
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);
|
.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);
|
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(?,?,?,?,?,?,?)'))
|
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', params.id, `moderation.${state}`, JSON.stringify({ reason, bannedUntil: bannedUntil?.toISOString() }), now);
|
.run(randomUUID(), operator.id, 'user_moderation', params.id, `moderation.${state}`, JSON.stringify(before ?? null), JSON.stringify({ reason, bannedUntil: bannedUntil?.toISOString() }), now);
|
||||||
return { moderationSaved: true };
|
return { moderationSaved: true };
|
||||||
},
|
},
|
||||||
deleteAccount: async ({ cookies, request, params }) => {
|
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('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('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(?,?,?,?,?,?,?)'))
|
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 };
|
return { identityUnlinked: true };
|
||||||
},
|
},
|
||||||
removeEmail: async ({ cookies, params }) => {
|
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 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('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(?,?,?,?,?,?,?)'))
|
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 };
|
return { emailRemoved: true };
|
||||||
},
|
},
|
||||||
removePasskey: async ({ cookies, request, params }) => {
|
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('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(?,?,?,?,?,?,?)'))
|
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 };
|
return { passkeyRemoved: true };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { fail } from '@sveltejs/kit';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { getPagination } from '$lib/server/pagination';
|
import { getPagination } from '$lib/server/pagination';
|
||||||
|
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||||
|
|
||||||
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
||||||
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
||||||
@@ -75,7 +76,7 @@ function readRange(form: FormData, tier: number, range: (typeof rangeNames)[numb
|
|||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
save: async ({ request, cookies }) => {
|
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 form = await request.formData();
|
||||||
const id = String(form.get('id') ?? '');
|
const id = String(form.get('id') ?? '');
|
||||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||||
@@ -178,6 +179,13 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
const weaponId = id || randomUUID();
|
const weaponId = id || randomUUID();
|
||||||
const now = new Date().toISOString();
|
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) {
|
if (id) {
|
||||||
const exists = await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id);
|
const exists = await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id);
|
||||||
if (!exists) return fail(404, { weaponError: 'Weapon not found.' });
|
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(?,?,?,?,?)'))
|
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);
|
.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() };
|
return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||||
},
|
},
|
||||||
archive: async ({ request, cookies }) => {
|
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 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 (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 };
|
return { weaponArchived: true };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user