feat(permissions): C-2 — присвоение кастомной роли пользователю (users.custom_role)

Миграция 055: ADD COLUMN users.custom_role (безопасно, без пересборки users).
Модель: users.role = функциональная база (встроенная, CHECK ок, драйвит ветки
контроллеров и резолв прав), users.custom_role = имя кастомной роли. updateRole
(PATCH /api/admin/users/:id/role) принимает кастомные роли → ставит base_roles[0]
как базу + custom_role=имя; встроенная → custom_role=NULL; неизвестная → 400.
authMiddleware/optionalAuth читают custom_role → req.user.customRole; requireRole
расширяет до effectiveRoles(customRole||role). Тесты custom-roles 7/7; backend без регрессий.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 15:03:41 +03:00
parent 5aa2dd1a4b
commit 7cdb2e2af2
4 changed files with 66 additions and 12 deletions
+18 -6
View File
@@ -243,9 +243,20 @@ function getUsers(req, res) {
/* ── PATCH /api/admin/users/:id/role ─────────────────────────────────── */
function updateRole(req, res) {
const { role } = req.body;
const allowed = ['student', 'teacher', 'admin', 'free_student'];
if (!allowed.includes(role))
return res.status(400).json({ error: `role must be one of: ${allowed.join(', ')}` });
const BUILTIN = ['student', 'teacher', 'admin', 'free_student'];
// Кастомная роль: хранится как функциональная база (base_roles[0]) в users.role
// + имя в users.custom_role. Встроенная роль: role = built-in, custom_role = NULL.
let baseRole = role, customRole = null;
if (!BUILTIN.includes(role)) {
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: `Unknown role: ${role}` });
let bases = [];
try { bases = JSON.parse(cr.base_roles || '[]'); } catch (_e) { bases = []; }
baseRole = bases.find(b => BUILTIN.includes(b)) || 'student';
customRole = role;
}
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
@@ -254,9 +265,10 @@ function updateRole(req, res) {
return res.status(400).json({ error: 'Cannot change your own role' });
const oldRole = db.prepare('SELECT role FROM users WHERE id = ?').get(req.params.id)?.role;
db.prepare('UPDATE users SET role = ?, token_version = token_version + 1 WHERE id = ?').run(role, req.params.id);
audit(req, 'user.role_change', `user:${req.params.id}`, `${oldRole} -> ${role}`);
res.json({ id: Number(req.params.id), role });
db.prepare('UPDATE users SET role = ?, custom_role = ?, token_version = token_version + 1 WHERE id = ?')
.run(baseRole, customRole, req.params.id);
audit(req, 'user.role_change', `user:${req.params.id}`, `${oldRole} -> ${customRole || baseRole}`);
res.json({ id: Number(req.params.id), role: customRole || baseRole, base: baseRole });
}
/* ── GET /api/admin/users/:id/sessions ───────────────────────────────── */