373 lines
13 KiB
TypeScript
373 lines
13 KiB
TypeScript
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() };
|
||
},
|
||
};
|