feat(permissions): C-1 — фундамент кастомных ролей (roles table + наследование гейтов)

Phase C, Stage C-1 (ветка feature/custom-roles): таблица roles (name, label,
base_roles JSON, is_builtin) + засев встроенных. auth.effectiveRoles(role) —
кастомная роль наследует base_roles (какие встроенные гейты проходит); встроенные
— быстрый путь без БД. requireRole() теперь проверяет пересечение allowed с
effectiveRoles → 111 существующих гейтов не задеты (встроенные ведут себя как
прежде). Дизайн: PHASE_C_DESIGN.md. Тест effectiveRoles 5/5; полный backend pass.

ВАЖНО (обнаружено): users.role в канон-схеме имеет CHECK (admin/teacher/student/
free_student), безопасно пересобрать users (FK от многих таблиц, миграции в txn)
нельзя → присвоение кастомной роли пользователю пойдёт через users.custom_role (C-2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 14:57:10 +03:00
parent a6ff965d80
commit 5aa2dd1a4b
4 changed files with 151 additions and 6 deletions
+23 -6
View File
@@ -32,13 +32,30 @@ function authMiddleware(req, res, next) {
}
}
/* Кастомные роли наследуют «базовые роли» (какие встроенные гейты проходят).
Встроенные роли — быстрый путь без обращения к БД. */
const BUILTIN_ROLES = new Set(['admin', 'teacher', 'student', 'free_student']);
let _roleBaseStmt = null;
function effectiveRoles(role) {
if (!role) return [];
if (BUILTIN_ROLES.has(role)) return [role];
try {
if (!_roleBaseStmt) _roleBaseStmt = db.prepare('SELECT base_roles FROM roles WHERE name = ?');
const row = _roleBaseStmt.get(role);
if (row && row.base_roles) {
const arr = JSON.parse(row.base_roles);
if (Array.isArray(arr) && arr.length) return arr.indexOf(role) >= 0 ? arr : arr.concat(role);
}
} catch (_e) { /* таблицы roles может не быть на старом инстансе — деградация к самой роли */ }
return [role];
}
function requireRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user?.role)) {
if (req.user) logDenied(req, 'forbidden', `роль ${req.user.role}; требуется ${roles.join('/')}`);
return res.status(403).json({ error: 'Forbidden' });
}
next();
const eff = effectiveRoles(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' });
};
}
@@ -130,4 +147,4 @@ function optionalAuth(req, res, next) {
next();
}
module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, perm, parentAuth };
module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, perm, parentAuth, effectiveRoles };