feat(permissions): A1 — зависимости между правами (requires) + план переработки

registry: поле requires (questions.delete→manage, templates.public→manage,
courses.interactive→manage, simulations.quiz→access), проброшено в byRole.
auth.requirePermission: вынесен isEnabled(); право = own AND все requires
(дочернее не работает без родителя). /me и /users/🆔 effective с учётом
requires + requires в ответе. UI permissions.js: каскад — дочернее с
невыполненной зависимостью неактивно (тумблер заблокирован + «Требует: …»).
Тест зависимости. План: plans/permissions-rework/PLAN.md. Backend 216 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 14:10:20 +03:00
parent e37432d812
commit 9ac2a612e0
6 changed files with 117 additions and 24 deletions
@@ -72,9 +72,11 @@ function getMyPermissions(req, res) {
for (const r of userRows) userMap[r.permission] = r.enabled === 1;
const defs = ALL_PERMISSIONS.filter(p => p.role === role);
const base = {};
defs.forEach(d => { base[d.key] = userMap[d.key] !== undefined ? userMap[d.key] : (roleMap[d.key] ?? !!d.default); });
const result = defs.map(d => ({
key: d.key,
effective: userMap[d.key] !== undefined ? userMap[d.key] : (roleMap[d.key] ?? !!d.default),
effective: base[d.key] && (d.requires || []).every(r => !!base[r]),
}));
res.json({ role, permissions: result });
}
@@ -101,13 +103,16 @@ function getUserPermissions(req, res) {
for (const r of userRows) userMap[r.permission] = r.enabled === 1;
const defs = ALL_PERMISSIONS.filter(p => p.role === target.role);
const base = {};
defs.forEach(d => { base[d.key] = userMap[d.key] !== undefined ? userMap[d.key] : (roleMap[d.key] ?? !!d.default); });
const result = defs.map(d => ({
key: d.key,
label: d.label,
desc: d.desc,
requires: d.requires || [],
roleVal: roleMap[d.key] ?? d.default, // effective role-level value
userVal: userMap[d.key], // undefined = no override
effective: userMap[d.key] !== undefined ? userMap[d.key] : (roleMap[d.key] ?? !!d.default),
effective: base[d.key] && (d.requires || []).every(r => !!base[r]),
}));
res.json({ role: target.role, permissions: result });
+16 -17
View File
@@ -42,7 +42,19 @@ function requireRole(...roles) {
};
}
/* ── Check permission; user override → role override → hardcoded default ── */
/* ── Разрешено ли ОДНО право: user override → role override → дефолт реестра ── */
function isEnabled(uid, role, key) {
const userRow = db.prepare(
'SELECT enabled FROM user_permissions WHERE user_id = ? AND permission = ?'
).get(uid, key);
if (userRow !== undefined) return userRow.enabled === 1;
const roleRow = db.prepare(
'SELECT enabled FROM role_permissions WHERE role = ? AND permission = ?'
).get(role, key);
return roleRow !== undefined ? roleRow.enabled === 1 : (PERM_DEFAULTS[role]?.[key] ?? false);
}
/* ── Проверка права с учётом зависимостей (requires): own AND все requires ── */
function requirePermission(key) {
return (req, res, next) => {
if (req.user?.role === 'admin') return next();
@@ -50,22 +62,9 @@ function requirePermission(key) {
const uid = req.user?.id;
if (!role) return res.status(401).json({ error: 'Unauthorized' });
// 1. User-level override
const userRow = db.prepare(
'SELECT enabled FROM user_permissions WHERE user_id = ? AND permission = ?'
).get(uid, key);
if (userRow !== undefined) {
if (userRow.enabled === 1) return next();
logDenied(req, 'perm_denied', key);
return res.status(403).json({ error: 'Permission denied' });
}
// 2. Role-level
const roleRow = db.prepare(
'SELECT enabled FROM role_permissions WHERE role = ? AND permission = ?'
).get(role, key);
const enabled = roleRow !== undefined ? roleRow.enabled === 1 : (PERM_DEFAULTS[role]?.[key] ?? false);
if (enabled) return next();
const reqs = (registry.PERMISSIONS[key] && registry.PERMISSIONS[key].requires) || [];
const ok = isEnabled(uid, role, key) && reqs.every(r => isEnabled(uid, role, r));
if (ok) return next();
logDenied(req, 'perm_denied', key);
return res.status(403).json({ error: 'Permission denied' });
};
+5 -1
View File
@@ -37,6 +37,7 @@ const PERMISSIONS = {
role: 'teacher', roles: ['teacher'], default: 0,
label: 'Удалять вопросы',
desc: 'Удалять вопросы из банка (требует "Управление вопросами")',
requires: ['questions.manage'],
},
'students.invite': {
role: 'teacher', roles: ['teacher'], default: 0,
@@ -90,6 +91,7 @@ const PERMISSIONS = {
role: 'teacher', roles: ['teacher'], default: 0,
label: 'Публикация шаблонов',
desc: 'Делать свои шаблоны публичными для всех учителей',
requires: ['templates.manage'],
},
'courses.manage': {
role: 'teacher', roles: ['teacher'], default: 1,
@@ -101,6 +103,7 @@ const PERMISSIONS = {
role: 'teacher', roles: ['teacher'], default: 1,
label: 'Интерактивные блоки',
desc: 'Добавлять интерактивные задания в уроки (сопоставление, пропуски, порядок)',
requires: ['courses.manage'],
},
'shop.manage': {
role: 'teacher', roles: ['teacher'], default: 0,
@@ -155,6 +158,7 @@ const PERMISSIONS = {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'Задания в симуляциях',
desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)',
requires: ['simulations.access'],
},
};
@@ -178,7 +182,7 @@ function listKeys() {
function byRole(role) {
return Object.entries(PERMISSIONS)
.filter(([, v]) => v.role === role)
.map(([key, v]) => ({ key, role: v.role, default: v.default, label: v.label, desc: v.desc, requireConfirmOff: !!v.requireConfirmOff }));
.map(([key, v]) => ({ key, role: v.role, default: v.default, label: v.label, desc: v.desc, requireConfirmOff: !!v.requireConfirmOff, requires: v.requires || [] }));
}
/**