Files
shd.build/src/routes/isac/roles/+page.server.ts
T
jb dd84dff055
Validate and deploy production / validate (push) Successful in 23s
Validate and deploy staging / validate (push) Successful in 50s
Validate and deploy production / deploy (push) Failing after 40s
Validate and deploy staging / deploy (push) Failing after 7s
feat: add community publishing and news syndication
2026-08-01 21:42:09 -04:00

373 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() };
},
};