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
+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>