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
+5 -5
View File
@@ -16,7 +16,7 @@ function authMiddleware(req, res, next) {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
// Re-fetch role + token_version from DB so changes take effect immediately
const fresh = db.prepare('SELECT role, token_version, is_banned FROM users WHERE id = ?').get(payload.id);
const fresh = db.prepare('SELECT role, custom_role, token_version, is_banned FROM users WHERE id = ?').get(payload.id);
if (!fresh) return res.status(401).json({ error: 'User not found' });
if (fresh.is_banned) return res.status(403).json({ error: 'Аккаунт заблокирован' });
// Invalidate tokens issued before password change / role change.
@@ -25,7 +25,7 @@ function authMiddleware(req, res, next) {
if (fresh.token_version != null && payload.tv !== fresh.token_version) {
return res.status(401).json({ error: 'Token revoked — please log in again' });
}
req.user = { ...payload, role: fresh.role };
req.user = { ...payload, role: fresh.role, customRole: fresh.custom_role || null };
next();
} catch {
res.status(401).json({ error: 'Token invalid or expired' });
@@ -52,7 +52,7 @@ function effectiveRoles(role) {
function requireRole(...roles) {
return (req, res, next) => {
const eff = effectiveRoles(req.user?.role);
const eff = effectiveRoles(req.user?.customRole || req.user?.role);
if (eff.some(r => roles.includes(r))) return next();
if (req.user) logDenied(req, 'forbidden', `роль ${req.user.role}; требуется ${roles.join('/')}`);
return res.status(403).json({ error: 'Forbidden' });
@@ -137,10 +137,10 @@ function optionalAuth(req, res, next) {
const token = header.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
const fresh = db.prepare('SELECT role, token_version, is_banned FROM users WHERE id = ?').get(payload.id);
const fresh = db.prepare('SELECT role, custom_role, token_version, is_banned FROM users WHERE id = ?').get(payload.id);
if (fresh && !fresh.is_banned) {
if (fresh.token_version == null || payload.tv === fresh.token_version) {
req.user = { ...payload, role: fresh.role };
req.user = { ...payload, role: fresh.role, customRole: fresh.custom_role || null };
}
}
} catch {}