import { getPermissions, requirePermission, } from '$lib/server/auth/authorization'; import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; import { getPagination, getRegistrySearch, getRegistrySort, } from '$lib/server/pagination'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; import { recordIsacAudit } from '$lib/server/audit'; const filters = ['isac', 'data', 'users', 'audit', 'system']; const seededRoleIds = new Set([ 'role-data-editor', 'role-user-operator', 'role-access-manager', 'role-admin', 'role-moderator', 'role-data-viewer', 'role-data-contributor', 'role-data-approver', 'role-auditor', ]); 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 { search, pattern } = getRegistrySearch(url); const sort = getRegistrySort( url, { name: { asc: 'roles.name COLLATE NOCASE ASC', desc: 'roles.name COLLATE NOCASE DESC', }, users: { asc: 'user_count ASC', desc: 'user_count DESC', defaultDirection: 'desc', }, permissions: { asc: '(SELECT COUNT(*) FROM role_permissions WHERE role_id=roles.id) ASC', desc: '(SELECT COUNT(*) FROM role_permissions WHERE role_id=roles.id) DESC', defaultDirection: 'desc', }, state: { asc: "CASE WHEN roles.id='role-admin' THEN 1 ELSE 0 END ASC", desc: "CASE WHEN roles.id='role-admin' THEN 1 ELSE 0 END DESC", }, }, 'name', ); const permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : filter === 'audit' ? 'audit.%' : filter === 'system' ? 'system.%' : ''; const relatedPermissionFilter = filter === 'data' ? " OR p.name LIKE 'calculation.%' OR p.name LIKE 'ai.%'" : filter === 'system' ? " OR p.name LIKE 'site.%' OR p.name LIKE 'metrics.%' OR p.name LIKE 'images.%'" : ''; const conditions = [ ...(permissionPrefix ? [ `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 ?${relatedPermissionFilter}) )`, ] : []), ...(search ? ["roles.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []), ]; const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const filterArgs = [ ...(permissionPrefix ? [permissionPrefix] : []), ...(search ? [pattern] : []), ]; 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.%' OR p.name LIKE 'calculation.%' OR p.name LIKE 'ai.%')) 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 'audit.%') THEN 1 ELSE 0 END) AS audit ,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 'system.%' OR p.name LIKE 'site.%' OR p.name LIKE 'metrics.%' OR p.name LIKE 'images.%')) THEN 1 ELSE 0 END) AS system 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 ${sort.orderBy},roles.name COLLATE NOCASE,roles.id 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)), systemRole: seededRoleIds.has(String(role.id)), protected: role.id === 'role-admin', canEdit: role.id !== 'role-admin' || canManageSystemAdmin, canDelete: role.id !== 'role-admin' && (canManageSystemAdmin || !seededRoleIds.has(String(role.id))), })), permissions, summary, filter, search, sort, 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' && !canManageSystemAdmin) { return fail(403, { roleError: 'Only a full administrator can modify the core Admin role.', }); } 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 recordIsacAudit({ actorId: operator.id, category: 'role_management', action: id ? 'role.updated' : 'role.created', entityId: roleId, before: id ? { name: existing?.name, description: existing?.description, permissions: beforeLinks.map((row) => row.name), } : null, after: { name, description: description || null, permissions: validPermissions.map((row) => row.name), }, }); return { roleSaved: true, roleMode: id ? 'edit' : 'add', resultToken: randomUUID(), }; }, delete: 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') ?? '').trim(); const confirmation = String(form.get('confirmation') ?? '') .normalize('NFKC') .trim(); if (!id) return fail(400, { roleDeleteError: 'Choose a role to delete.' }); if (id === 'role-admin') { return fail(403, { roleDeleteError: 'The core Admin role cannot be deleted.', }); } if (seededRoleIds.has(id) && !canManageSystemAdmin) { return fail(403, { roleDeleteError: 'Only a full administrator can delete a seeded system role.', }); } const database = await getDatabase(); const role = await ( await database.prepare('SELECT id,name,description FROM roles WHERE id=?') ).get(id); if (!role) return fail(404, { roleDeleteError: 'Role not found.' }); if (confirmation !== String(role.name)) { return fail(400, { roleDeleteError: 'Enter the exact role name to confirm deletion.', }); } const [permissions, assignedUsers] = await Promise.all([ ( await database.prepare(`SELECT permissions.name FROM role_permissions JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_permissions.role_id=? ORDER BY permissions.name COLLATE NOCASE`) ).all(id), ( await database.prepare(`SELECT users.id, COALESCE(user_profiles.display_name,users.username) AS display_name FROM user_roles JOIN users ON users.id=user_roles.user_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE user_roles.role_id=? ORDER BY display_name COLLATE NOCASE,users.id`) ).all(id), ]); await database.exec('BEGIN IMMEDIATE'); try { if (seededRoleIds.has(id)) { await ( await database.prepare(`INSERT INTO deleted_roles(role_id,deleted_at,deleted_by) VALUES(?,?,?) ON CONFLICT(role_id) DO UPDATE SET deleted_at=excluded.deleted_at, deleted_by=excluded.deleted_by`) ).run(id, new Date().toISOString(), operator.id); } await ( await database.prepare('DELETE FROM user_roles WHERE role_id=?') ).run(id); await ( await database.prepare('DELETE FROM role_permissions WHERE role_id=?') ).run(id); const deleted = await ( await database.prepare('DELETE FROM roles WHERE id=?') ).run(id); if (!deleted.changes) throw new Error('The role changed before it could be deleted.'); await recordIsacAudit({ actorId: operator.id, category: 'role_management', action: 'role.deleted', entityId: id, before: { name: String(role.name), description: role.description ? String(role.description) : null, permissions: permissions.map((row) => String(row.name)), assignedUsers: assignedUsers.map((row) => ({ id: String(row.id), displayName: String(row.display_name), })), }, after: { deleted: true, systemRole: seededRoleIds.has(id), removedAssignmentCount: assignedUsers.length, }, }); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } return { roleDeleted: true, resultToken: randomUUID() }; }, };