Add ISAC role management

This commit is contained in:
JB
2026-07-25 03:36:22 -04:00
parent ddec42afd3
commit bb9ee3785c
6 changed files with 167 additions and 5 deletions
+7
View File
@@ -209,6 +209,13 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps
unrestricted administrative role; `Moderator` is reserved for community
content moderation and does not imply ISAC access. Operational ISAC access is
delegated separately through scoped roles and permissions.
- Roles & Permissions is a separate User Operations registry. Role definitions
compose discrete capabilities through a dense permission catalogue with an
explicit selected count and textual `GRANTED` / `NOT GRANTED` states. The
built-in Admin role is visible but system-protected. Delegated access managers
must never be able to grant `system.admin`; both option visibility and
server-side validation enforce this boundary. Role mutations are recorded in
the audit log with their previous and resulting permission sets.
- 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,
+1
View File
@@ -104,4 +104,5 @@
@media(max-width:1200px){.weapon-identity-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.weapon-fixed-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.weapon-tier-grid{grid-template-columns:1fr}.weapon-talent-pools{grid-template-columns:1fr}}@media(max-width:700px){.weapon-identity-grid,.weapon-fixed-grid{grid-template-columns:1fr 1fr}.compact-selection{grid-template-columns:1fr}.base-bonus-row,.augment-effect,.augment-effect:not(:has(>button)){grid-template-columns:30px 1fr}.base-bonus-row .field:nth-of-type(2),.augment-effect .field:nth-of-type(2){grid-column:2}.base-bonus-row>button,.augment-effect>button{grid-column:2}.augment-sequence{grid-template-columns:1fr 1fr}}@media(max-width:480px){.weapon-identity-grid,.weapon-fixed-grid{grid-template-columns:1fr}.weapon-range-row{grid-template-columns:45px 1fr 1fr}}
.legal-table-wrap{overflow-x:auto;margin:18px 0}.legal-table{width:100%;min-width:720px;border-collapse:collapse;border:1px solid var(--shd-border);background:#0b0e0f}.legal-table th,.legal-table td{padding:12px 14px;border:1px solid var(--shd-border);text-align:left;vertical-align:top}.legal-table th{color:var(--shd-orange);background:#111516;font-size:7px;letter-spacing:1px}.legal-table td{color:#a1a7a8;font:12px/1.55 Georgia,serif}.legal-table code,.legal-document code{color:#d9dedf;font:11px Consolas,monospace}.cookie-notice{position:fixed;z-index:1000;right:24px;bottom:24px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:22px;width:min(620px,calc(100vw - 48px));padding:18px 20px;border:1px solid var(--shd-orange);background:rgba(10,13,14,.98);box-shadow:0 14px 48px #000c,4px 0 0 var(--shd-orange)}.cookie-notice .eyebrow{margin:0 0 8px}.cookie-notice div>p:not(.eyebrow){margin:0;color:var(--shd-text-muted);font:12px/1.5 Georgia,serif}.cookie-notice a{display:inline-block;margin-top:10px;color:var(--shd-orange);font-size:7px;font-weight:900;letter-spacing:1px}.cookie-notice button{min-width:84px}@media(max-width:600px){.cookie-notice{right:12px;bottom:12px;grid-template-columns:1fr;width:calc(100vw - 24px)}.cookie-notice button{width:100%}}
.kpi-filters{height:auto}.kpi-filters>a{position:relative;min-height:70px;padding:11px 13px;background:#111415;border:1px solid #2d3233;border-top:2px solid #3a4041;display:grid;color:inherit;cursor:pointer}.kpi-filters>a:hover,.kpi-filters>a:focus-visible{border-color:#8a672b;border-top-color:var(--shd-orange);background:#171a1b}.kpi-filters>a.active{border-color:#8a672b;border-top-color:var(--shd-orange);background:#211a0e;box-shadow:inset 0 -2px #f6a821}.kpi-filters>a:active{transform:translateY(1px);filter:brightness(.88)}.kpi-filters>a span{font-size:7px;letter-spacing:1.3px;color:#737a7c}.kpi-filters>a strong{font-size:23px;line-height:1.1;margin:3px 0}.kpi-filters>a small{font-size:7px;color:#555d5f}.kpi-filters>a.active span,.kpi-filters>a.active strong{color:var(--shd-orange)}
.role-identity{grid-template-columns:minmax(220px,.7fr) minmax(320px,1.3fr)!important}.permission-catalogue{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:8px}.permission-catalogue label{min-height:54px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:9px;padding:8px 10px;border:1px solid #303536;background:#0d1011;cursor:pointer}.permission-catalogue label:hover,.permission-catalogue label:focus-within{border-color:#8a672b;background:#151819}.permission-catalogue label:active{transform:translateY(1px);filter:brightness(.88)}.permission-catalogue input{width:auto;margin:0;accent-color:var(--shd-orange)}.permission-catalogue span{display:grid;gap:4px}.permission-catalogue b{color:#d7d9d5;font-size:9px}.permission-catalogue small{color:#626a6c;font-size:7px;line-height:1.35}.permission-catalogue em{color:#596164;font-size:6px;font-style:normal;font-weight:900;letter-spacing:.7px}.permission-catalogue label:has(input:checked){border-color:#6e5226;background:#211a0e}.permission-catalogue label:has(input:checked) em{color:var(--shd-orange)}.role-row{min-width:960px;grid-template-columns:1.1fr .55fr 2fr .65fr .3fr}.role-permission-summary{display:flex!important;flex-wrap:wrap;gap:3px}.role-permission-summary b{padding:3px 5px;border:1px solid #3a4840;color:#75c08c;font-size:6px;letter-spacing:.5px}@media(max-width:700px){.permission-catalogue,.role-identity{grid-template-columns:1fr!important}}
+9 -5
View File
@@ -9,7 +9,11 @@
{ href: '/admin/gear', label: 'Gear', code: 'GEAR' },
{ href: '/admin/weapons', label: 'Weapons', code: 'WPN' }
];
const userSections = [{ href: '/admin/users', label: 'User operations', code: 'USER' }];
const userSections = [
{ href: '/admin/users', label: 'User operations', code: 'USER', permission: 'users.view' },
{ href: '/admin/roles', label: 'Roles & permissions', code: 'ROLE', permission: 'users.roles' }
];
const visibleUserSections = $derived(userSections.filter((section) => can(section.permission)));
</script>
<main class="admin-page">
@@ -19,12 +23,12 @@
</header>
<div class="admin-dashboard">
<aside class="admin-sidebar">
{#if can('users.view')}
<div class="sidebar-label"><span>USER OPERATIONS</span><b>01 MODULE</b></div>
<nav aria-label="User operations">{#each userSections 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 visibleUserSections.length}
<div class="sidebar-label"><span>USER OPERATIONS</span><b>{String(visibleUserSections.length).padStart(2, '0')} MODULE{visibleUserSections.length === 1 ? '' : 'S'}</b></div>
<nav aria-label="User operations">{#each visibleUserSections 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}
{#if can('data.manage')}
<div class="sidebar-label" class:section-label={can('users.view')}><span>DATA MANAGEMENT</span><b>05 MODULES</b></div>
<div class="sidebar-label" class:section-label={visibleUserSections.length > 0}><span>DATA MANAGEMENT</span><b>05 MODULES</b></div>
<nav aria-label="Data management">
{#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>
+1
View File
@@ -6,6 +6,7 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const permissions = await getPermissions(await getSessionUser(cookies));
if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/admin/users');
if (permissions.includes('users.roles')) redirect(303, '/admin/roles');
if (permissions.includes('data.manage')) redirect(303, '/admin/stats');
redirect(303, '/account');
};
+97
View File
@@ -0,0 +1,97 @@
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 { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
const filters = ['isac', 'data', 'users'];
export const load: PageServerLoad = async ({ cookies, url }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
const operatorPermissions = await getPermissions(operator);
const canManageSystemAdmin = operatorPermissions.includes('*');
const database = await getDatabase();
const filter = filters.includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
const permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : '';
const where = permissionPrefix ? `WHERE EXISTS (
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
WHERE rp.role_id=roles.id AND p.name LIKE ?
)` : '';
const filterArgs = permissionPrefix ? [permissionPrefix] : [];
const [summary, filtered, permissions] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'isac.%') THEN 1 ELSE 0 END) AS isac,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'data.%') THEN 1 ELSE 0 END) AS data,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'users.%') THEN 1 ELSE 0 END) AS users
FROM roles`)).get(),
(await database.prepare(`SELECT COUNT(*) AS total FROM roles ${where}`)).get(...filterArgs),
(await database.prepare(`SELECT id,name,description FROM permissions
${canManageSystemAdmin ? '' : "WHERE name<>'system.admin'"} ORDER BY name COLLATE NOCASE`)).all()
]);
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const roles = await (await database.prepare(`SELECT roles.*,
(SELECT COUNT(*) FROM user_roles WHERE role_id=roles.id) AS user_count
FROM roles ${where} ORDER BY CASE WHEN roles.id='role-admin' THEN 0 ELSE 1 END,name COLLATE NOCASE
LIMIT ? OFFSET ?`)).all(...filterArgs, pagination.perPage, pagination.offset);
const roleIds = roles.map((role) => String(role.id));
const links = roleIds.length ? await (await database.prepare(`SELECT role_id,permission_id FROM role_permissions
WHERE role_id IN (${roleIds.map(() => '?').join(',')})`)).all(...roleIds) : [];
return {
roles: roles.map((role) => ({
...role,
permissionIds: links.filter((link) => link.role_id === role.id).map((link) => String(link.permission_id)),
protected: role.id === 'role-admin'
})),
permissions,
summary,
filter,
pagination,
canManageSystemAdmin
};
};
export const actions: Actions = {
save: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
const operatorPermissions = await getPermissions(operator);
const canManageSystemAdmin = operatorPermissions.includes('*');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const description = String(form.get('description') ?? '').normalize('NFKC').trim();
const permissionIds = [...new Set(form.getAll('permissionIds').map(String))];
if (name.length < 2 || name.length > 60) return fail(400, { roleError: 'Role names must be 2–60 characters.' });
if (description.length > 240) return fail(400, { roleError: 'Descriptions must be 240 characters or fewer.' });
const database = await getDatabase();
const existing = id ? await (await database.prepare('SELECT id,name,description FROM roles WHERE id=?')).get(id) : null;
if (id && !existing) return fail(404, { roleError: 'Role not found.' });
if (id === 'role-admin') return fail(403, { roleError: 'The built-in Admin role is protected and cannot be modified.' });
if (await (await database.prepare('SELECT id FROM roles WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
return fail(409, { roleError: 'A role with that name already exists.' });
}
const validPermissions = permissionIds.length ? await (await database.prepare(`SELECT id,name FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`)).all(...permissionIds) : [];
if (validPermissions.length !== permissionIds.length) return fail(400, { roleError: 'One or more permissions are invalid.' });
if (!canManageSystemAdmin && validPermissions.some((permission) => permission.name === 'system.admin')) {
return fail(403, { roleError: 'Only a full administrator can grant unrestricted system access.' });
}
const roleId = id || randomUUID();
const beforeLinks = id ? 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(id) : [];
if (id) await (await database.prepare('UPDATE roles SET name=?,description=? WHERE id=?')).run(name, description || null, id);
else await (await database.prepare('INSERT INTO roles(id,name,description) VALUES(?,?,?)')).run(roleId, name, description || null);
await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(roleId);
for (const permissionId of permissionIds) {
await (await database.prepare('INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)')).run(roleId, permissionId);
}
await (await database.prepare(`INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at)
VALUES(?,?,?,?,?,?,?,?)`)).run(
randomUUID(), operator.id, 'role', roleId, id ? 'role.updated' : 'role.created',
id ? JSON.stringify({ name: existing?.name, description: existing?.description, permissions: beforeLinks.map((row) => row.name) }) : null,
JSON.stringify({ name, description: description || null, permissions: validPermissions.map((row) => row.name) }),
new Date().toISOString()
);
return { roleSaved: true, roleMode: id ? 'edit' : 'add', resultToken: randomUUID() };
}
};
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import KpiFilters from '$lib/components/KpiFilters.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
let { data, form } = $props();
let editing = $state<any>(null);
let handledResultToken = $state('');
const blank = () => ({ id: '', name: '', description: '', permissionIds: [] });
const permissionName = (id: string) => data.permissions.find((permission) => permission.id === id)?.name ?? id;
$effect(() => {
if (!form?.roleSaved || !form.resultToken || form.resultToken === handledResultToken) return;
handledResultToken = form.resultToken;
editing = form.roleMode === 'add' ? blank() : null;
});
</script>
<svelte:head><title>Roles & permissions — ISAC</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ACCESS POLICY REGISTRY</p><h2>ROLES & PERMISSIONS</h2><p>Compose reusable access roles from discrete SHD.build capabilities.</p></div><button class="primary" onclick={() => editing = blank()}>ADD ROLE <span>+</span></button></div>
<KpiFilters active={data.filter} items={[
{value:'',label:'TOTAL ROLES',count:data.summary.total,description:'Show all role definitions'},
{value:'isac',label:'ISAC ACCESS',count:data.summary.isac,description:'Can open the console'},
{value:'data',label:'DATA OPERATIONS',count:data.summary.data,description:'Builder-data capabilities'},
{value:'users',label:'USER OPERATIONS',count:data.summary.users,description:'User-management capabilities'}
]}/>
{#if editing}
<div class="dashboard-editor role-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY ACCESS ROLE' : 'NEW ACCESS ROLE'}</p><h3>{editing.id ? editing.name : 'ROLE DEFINITION'}</h3></div><button aria-label="Close role editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save">
<input type="hidden" name="id" value={editing.id}/>
<div class="form-grid role-identity"><label class="field">ROLE NAME<input name="name" bind:value={editing.name} minlength="2" maxlength="60" required/></label><label class="field">DESCRIPTION<input name="description" bind:value={editing.description} maxlength="240" placeholder="Purpose and intended operators"/></label></div>
<div class="selection-head"><div><span>DISCRETE PERMISSIONS</span><small>ONLY SELECT THE CAPABILITIES THIS ROLE REQUIRES</small></div><b>{editing.permissionIds.length} SELECTED</b></div>
<div class="permission-catalogue">
{#each data.permissions as permission}
<label><input type="checkbox" name="permissionIds" value={permission.id} bind:group={editing.permissionIds}/><span><b>{permission.name}</b><small>{permission.description}</small></span><em>{editing.permissionIds.includes(permission.id) ? 'GRANTED' : 'NOT GRANTED'}</em></label>
{/each}
</div>
{#if !data.canManageSystemAdmin}<p class="console-note">UNRESTRICTED SYSTEM ADMINISTRATION IS HIDDEN AND MAY ONLY BE GRANTED BY A FULL ADMINISTRATOR.</p>{/if}
{#if form?.roleError}<p class="error">{form.roleError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE ROLE →</button></div>
</PendingForm>
</div>
{/if}
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table">
<div class="dashboard-row role-row table-heading"><span>ROLE</span><span>ASSIGNED USERS</span><span>DISCRETE PERMISSIONS</span><span>STATE</span><span></span></div>
{#each data.roles as role}
<div class="dashboard-row role-row"><div><strong>{role.name}</strong><small>{role.description ?? 'NO DESCRIPTION'}</small></div><span>{role.user_count} USER{Number(role.user_count) === 1 ? '' : 'S'}</span><div class="role-permission-summary">{#each role.permissionIds as permissionId}<b>{permissionName(permissionId)}</b>{:else}<small>NO PERMISSIONS</small>{/each}</div><span class:configured={!role.protected}>{role.protected ? 'SYSTEM PROTECTED' : 'EDITABLE'}</span>{#if role.protected}<span></span>{:else}<button onclick={() => editing = {...role, permissionIds:[...role.permissionIds]}}>EDIT</button>{/if}</div>
{:else}<div class="empty-dashboard"><strong>NO ROLES MATCH THIS FILTER</strong><span>Select another access domain or create a role.</span></div>{/each}
</div>