feat(permissions): C-4a — API конструктора ролей (/api/roles, admin)

rolesController + routes/roles (admin, inline guards): GET список (с числом
пользователей), POST создать кастомную роль (имя-идентификатор + метка + base_roles;
засев прав из функциональной базы), PUT изменить, DELETE удалить (пользователей
возвращает на базу), GET /:name/permissions (эффективная карта база+оверлей + defs).
setPermission теперь принимает кастомные роли (ключ валидируется по базе, хранится
под именем роли). Смонтировано в server.js + тест-харнесс. Тест roles-api 5/5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 15:21:44 +03:00
parent 32c2c44b76
commit bdc8bef857
6 changed files with 215 additions and 3 deletions
@@ -38,9 +38,21 @@ function getPermissions(_req, res) {
/* ── POST /api/permissions { role, permission, enabled } ─────────────── */
function setPermission(req, res) {
const { role, permission, enabled } = req.body;
if (!['teacher', 'student'].includes(role))
return res.status(400).json({ error: 'Invalid role' });
if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === role))
// Встроенные конфигурируемые роли — напрямую; кастомная роль — ключи валидируем
// по её функциональной базе, но храним под именем роли.
let keyRole;
if (['teacher', 'student'].includes(role)) {
keyRole = role;
} else {
let cr = null;
try { cr = db.prepare('SELECT base_roles, is_builtin FROM roles WHERE name = ?').get(role); } catch (_e) { cr = null; }
if (!cr || cr.is_builtin) return res.status(400).json({ error: 'Invalid role' });
let bases = [];
try { bases = JSON.parse(cr.base_roles || '[]'); } catch (_e) { bases = []; }
const primary = bases.find(b => ['teacher', 'student', 'free_student'].includes(b)) || 'student';
keyRole = primary === 'free_student' ? 'student' : primary;
}
if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === keyRole))
return res.status(400).json({ error: 'Unknown permission' });
// Серверное применение прав — ЖИВОЕ: requirePermission() читает role_permissions
// из БД на каждый запрос (auth.js). Поэтому role-level изменение НЕ инвалидирует