diff --git a/backend/src/controllers/permissionsController.js b/backend/src/controllers/permissionsController.js index 7906b26..e7c581e 100644 --- a/backend/src/controllers/permissionsController.js +++ b/backend/src/controllers/permissionsController.js @@ -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 }); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 70628f5..df69919 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -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' }); }; diff --git a/backend/src/permissions/registry.js b/backend/src/permissions/registry.js index 8ccde6f..f107080 100644 --- a/backend/src/permissions/registry.js +++ b/backend/src/permissions/registry.js @@ -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 || [] })); } /** diff --git a/backend/tests/permissions.test.js b/backend/tests/permissions.test.js index 1ba9b29..fb0c40d 100644 --- a/backend/tests/permissions.test.js +++ b/backend/tests/permissions.test.js @@ -174,4 +174,21 @@ describe('Permissions', () => { }, adminToken); assert.equal(res.status, 400); }); + + // ── 10. A1: зависимости (requires) ───────────────────────────────────────── + it('зависимость requires: simulations.quiz неэффективен при выключенном simulations.access', async () => { + const off = await inject('POST', '/api/permissions', + { role: 'student', permission: 'simulations.access', enabled: false }, adminToken); + assert.equal(off.status, 200); + const view = await inject('GET', `/api/permissions/users/${studentUser.userId}`, null, adminToken); + assert.equal(view.status, 200); + const quiz = view.body.permissions.find(p => p.key === 'simulations.quiz'); + const acc = view.body.permissions.find(p => p.key === 'simulations.access'); + assert.equal(acc.effective, false, 'родитель simulations.access выключен'); + assert.equal(quiz.effective, false, 'дочернее simulations.quiz неэффективно из-за requires'); + assert.deepEqual(quiz.requires, ['simulations.access'], 'requires проброшен в API'); + // restore + await inject('POST', '/api/permissions', + { role: 'student', permission: 'simulations.access', enabled: true }, adminToken); + }); }); diff --git a/frontend/js/admin/sections/permissions.js b/frontend/js/admin/sections/permissions.js index 35a888f..47fec9b 100644 --- a/frontend/js/admin/sections/permissions.js +++ b/frontend/js/admin/sections/permissions.js @@ -21,20 +21,30 @@ ['teacher', 'student'].forEach(role => { const container = document.getElementById('perm-' + role); const defs = definitions.filter(d => d.role === role); + const en = {}, labelOf = {}; + defs.forEach(d => { en[d.key] = permissions[role]?.[d.key] ?? d.default; labelOf[d.key] = d.label; }); container.innerHTML = defs.map(def => { - const enabled = permissions[role]?.[def.key] ?? def.default; + const enabled = en[def.key]; + const reqs = def.requires || []; + const unmet = reqs.filter(r => !en[r]); + const blocked = unmet.length > 0; // зависимость не выполнена → право неактивно + const effective = enabled && !blocked; const isModified = (enabled ? 1 : 0) !== def.default; const modDot = isModified ? `` : ''; + const reqNote = reqs.length + ? `