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:
@@ -72,9 +72,11 @@ function getMyPermissions(req, res) {
|
|||||||
for (const r of userRows) userMap[r.permission] = r.enabled === 1;
|
for (const r of userRows) userMap[r.permission] = r.enabled === 1;
|
||||||
|
|
||||||
const defs = ALL_PERMISSIONS.filter(p => p.role === role);
|
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 => ({
|
const result = defs.map(d => ({
|
||||||
key: d.key,
|
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 });
|
res.json({ role, permissions: result });
|
||||||
}
|
}
|
||||||
@@ -101,13 +103,16 @@ function getUserPermissions(req, res) {
|
|||||||
for (const r of userRows) userMap[r.permission] = r.enabled === 1;
|
for (const r of userRows) userMap[r.permission] = r.enabled === 1;
|
||||||
|
|
||||||
const defs = ALL_PERMISSIONS.filter(p => p.role === target.role);
|
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 => ({
|
const result = defs.map(d => ({
|
||||||
key: d.key,
|
key: d.key,
|
||||||
label: d.label,
|
label: d.label,
|
||||||
desc: d.desc,
|
desc: d.desc,
|
||||||
|
requires: d.requires || [],
|
||||||
roleVal: roleMap[d.key] ?? d.default, // effective role-level value
|
roleVal: roleMap[d.key] ?? d.default, // effective role-level value
|
||||||
userVal: userMap[d.key], // undefined = no override
|
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 });
|
res.json({ role: target.role, permissions: result });
|
||||||
|
|||||||
@@ -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) {
|
function requirePermission(key) {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
if (req.user?.role === 'admin') return next();
|
if (req.user?.role === 'admin') return next();
|
||||||
@@ -50,22 +62,9 @@ function requirePermission(key) {
|
|||||||
const uid = req.user?.id;
|
const uid = req.user?.id;
|
||||||
if (!role) return res.status(401).json({ error: 'Unauthorized' });
|
if (!role) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
|
||||||
// 1. User-level override
|
const reqs = (registry.PERMISSIONS[key] && registry.PERMISSIONS[key].requires) || [];
|
||||||
const userRow = db.prepare(
|
const ok = isEnabled(uid, role, key) && reqs.every(r => isEnabled(uid, role, r));
|
||||||
'SELECT enabled FROM user_permissions WHERE user_id = ? AND permission = ?'
|
if (ok) return next();
|
||||||
).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();
|
|
||||||
logDenied(req, 'perm_denied', key);
|
logDenied(req, 'perm_denied', key);
|
||||||
return res.status(403).json({ error: 'Permission denied' });
|
return res.status(403).json({ error: 'Permission denied' });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const PERMISSIONS = {
|
|||||||
role: 'teacher', roles: ['teacher'], default: 0,
|
role: 'teacher', roles: ['teacher'], default: 0,
|
||||||
label: 'Удалять вопросы',
|
label: 'Удалять вопросы',
|
||||||
desc: 'Удалять вопросы из банка (требует "Управление вопросами")',
|
desc: 'Удалять вопросы из банка (требует "Управление вопросами")',
|
||||||
|
requires: ['questions.manage'],
|
||||||
},
|
},
|
||||||
'students.invite': {
|
'students.invite': {
|
||||||
role: 'teacher', roles: ['teacher'], default: 0,
|
role: 'teacher', roles: ['teacher'], default: 0,
|
||||||
@@ -90,6 +91,7 @@ const PERMISSIONS = {
|
|||||||
role: 'teacher', roles: ['teacher'], default: 0,
|
role: 'teacher', roles: ['teacher'], default: 0,
|
||||||
label: 'Публикация шаблонов',
|
label: 'Публикация шаблонов',
|
||||||
desc: 'Делать свои шаблоны публичными для всех учителей',
|
desc: 'Делать свои шаблоны публичными для всех учителей',
|
||||||
|
requires: ['templates.manage'],
|
||||||
},
|
},
|
||||||
'courses.manage': {
|
'courses.manage': {
|
||||||
role: 'teacher', roles: ['teacher'], default: 1,
|
role: 'teacher', roles: ['teacher'], default: 1,
|
||||||
@@ -101,6 +103,7 @@ const PERMISSIONS = {
|
|||||||
role: 'teacher', roles: ['teacher'], default: 1,
|
role: 'teacher', roles: ['teacher'], default: 1,
|
||||||
label: 'Интерактивные блоки',
|
label: 'Интерактивные блоки',
|
||||||
desc: 'Добавлять интерактивные задания в уроки (сопоставление, пропуски, порядок)',
|
desc: 'Добавлять интерактивные задания в уроки (сопоставление, пропуски, порядок)',
|
||||||
|
requires: ['courses.manage'],
|
||||||
},
|
},
|
||||||
'shop.manage': {
|
'shop.manage': {
|
||||||
role: 'teacher', roles: ['teacher'], default: 0,
|
role: 'teacher', roles: ['teacher'], default: 0,
|
||||||
@@ -155,6 +158,7 @@ const PERMISSIONS = {
|
|||||||
role: 'student', roles: ['student', 'free_student'], default: 1,
|
role: 'student', roles: ['student', 'free_student'], default: 1,
|
||||||
label: 'Задания в симуляциях',
|
label: 'Задания в симуляциях',
|
||||||
desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)',
|
desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)',
|
||||||
|
requires: ['simulations.access'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -178,7 +182,7 @@ function listKeys() {
|
|||||||
function byRole(role) {
|
function byRole(role) {
|
||||||
return Object.entries(PERMISSIONS)
|
return Object.entries(PERMISSIONS)
|
||||||
.filter(([, v]) => v.role === role)
|
.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 || [] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -174,4 +174,21 @@ describe('Permissions', () => {
|
|||||||
}, adminToken);
|
}, adminToken);
|
||||||
assert.equal(res.status, 400);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,20 +21,30 @@
|
|||||||
['teacher', 'student'].forEach(role => {
|
['teacher', 'student'].forEach(role => {
|
||||||
const container = document.getElementById('perm-' + role);
|
const container = document.getElementById('perm-' + role);
|
||||||
const defs = definitions.filter(d => d.role === 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 => {
|
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 isModified = (enabled ? 1 : 0) !== def.default;
|
||||||
const modDot = isModified
|
const modDot = isModified
|
||||||
? `<span class="perm-modified-dot" title="Отличается от значения по умолчанию"></span>`
|
? `<span class="perm-modified-dot" title="Отличается от значения по умолчанию"></span>`
|
||||||
: '';
|
: '';
|
||||||
|
const reqNote = reqs.length
|
||||||
|
? `<div class="perm-desc" style="margin-top:3px;color:${blocked ? 'var(--danger,#dc2626)' : 'var(--muted)'}">${blocked ? 'Требует: ' + unmet.map(r => esc(labelOf[r] || r)).join(', ') : 'Зависит от: ' + reqs.map(r => esc(labelOf[r] || r)).join(', ')}</div>`
|
||||||
|
: '';
|
||||||
return `
|
return `
|
||||||
<div class="perm-card${enabled ? ' enabled' : ''}" id="perm-card-${role}-${def.key.replace('.','_')}">
|
<div class="perm-card${effective ? ' enabled' : ''}" id="perm-card-${role}-${def.key.replace('.','_')}" style="${blocked ? 'opacity:.65' : ''}">
|
||||||
<div class="perm-info">
|
<div class="perm-info">
|
||||||
<div class="perm-label">${esc(def.label)}${modDot}</div>
|
<div class="perm-label">${esc(def.label)}${modDot}</div>
|
||||||
<div class="perm-desc">${esc(def.desc)}</div>
|
<div class="perm-desc">${esc(def.desc)}</div>
|
||||||
|
${reqNote}
|
||||||
</div>
|
</div>
|
||||||
<label class="perm-toggle" title="${enabled ? 'Выключить' : 'Включить'}">
|
<label class="perm-toggle" title="${blocked ? 'Сначала включите зависимость' : (enabled ? 'Выключить' : 'Включить')}">
|
||||||
<input type="checkbox" ${enabled ? 'checked' : ''}
|
<input type="checkbox" ${enabled ? 'checked' : ''} ${blocked ? 'disabled' : ''}
|
||||||
onchange="togglePermission('${esc(role)}','${esc(def.key)}',this.checked,this)">
|
onchange="togglePermission('${esc(role)}','${esc(def.key)}',this.checked,this)">
|
||||||
<span class="perm-track"></span>
|
<span class="perm-track"></span>
|
||||||
<span class="perm-thumb"></span>
|
<span class="perm-thumb"></span>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# PLAN — Переработка ролевых прав (Role Permissions Rework)
|
||||||
|
|
||||||
|
> Составлен 2026-06-03 (Opus) по итогам разбора. Цель — сделать модель ролевых прав корректной
|
||||||
|
> (зависимости), прозрачной (история, линт), гибкой (группы, пресеты, кастомные роли) и убрать
|
||||||
|
> болезненный сайд-эффект (массовый разлогин при смене права).
|
||||||
|
>
|
||||||
|
> Факты по текущему коду (проверено):
|
||||||
|
> - `backend/src/permissions/registry.js` — 23 ключа, роли teacher/student (+free_student зеркалит student);
|
||||||
|
> поля `role/roles/default/label/desc/requireConfirmOff`.
|
||||||
|
> - Резолв: `requirePermission` (`middleware/auth.js:46`) читает **живьём из БД**: user_permissions →
|
||||||
|
> role_permissions → дефолт реестра. `authMiddleware` каждый запрос перечитывает роль из БД.
|
||||||
|
> - Таблицы `role_permissions(role,permission,enabled)`, `user_permissions(user_id,permission,enabled)`.
|
||||||
|
> - `permissionsController.js`: GET /api/permissions, POST (role-level), GET /me, GET/POST /users/:id, DELETE reset.
|
||||||
|
> На каждое изменение — `token_version++` (role-level → у ВСЕХ юзеров роли; user-level → у одного).
|
||||||
|
> - UI: `frontend/js/admin/sections/permissions.js` (тумблеры по ролям) + `user-detail.js` (оверрайды).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase A — Быстрые победы (низкий риск)
|
||||||
|
- [ ] **A1. Зависимости между правами (`requires`).** Поле `requires:[...]` в реестре
|
||||||
|
(`questions.delete`→`questions.manage`; `templates.public`→`templates.manage`;
|
||||||
|
`courses.interactive`→`courses.manage`; `simulations.quiz`→`simulations.access`).
|
||||||
|
Enforce в `requirePermission` (effective = own AND все requires). Учесть в `/me` и `/users/:id`
|
||||||
|
(effective с учётом requires). UI: каскад (родитель off → дочерние серые/выключены). Тест.
|
||||||
|
- [ ] **A2. Гигиена реестра + ясные метки.** Тест-линт: каждый ключ из `requirePermission/perm(...)` в коде
|
||||||
|
есть в реестре; предупреждать о неиспользуемых ключах. Метки `theory.access`/`simulations.access`
|
||||||
|
переформулировать как «… доступен роли» (видимость конкретного — по классам в content_access).
|
||||||
|
- [ ] **A3. История изменений прав в UI.** Аудит уже пишется (`permission.set`/`permission.user_set`/
|
||||||
|
`permission.user_reset`). Эндпоинт `GET /api/permissions/log` (+ опц. фильтр по роли/пользователю),
|
||||||
|
кнопка «История» на вкладке «Доступ · роли» и в карточке пользователя. Переиспользовать паттерн
|
||||||
|
`/api/access/log`.
|
||||||
|
- [ ] **A4. Смягчить `token_version++`.** Сервер уже применяет права live → массовый разлогин роли при
|
||||||
|
одном тумблере не нужен. Убрать bump на role-level (и/или user-level), вместо этого клиент
|
||||||
|
пере-запрашивает `/permissions/me` при `focus`/навигации. Сохранить немедленный серверный эффект
|
||||||
|
(он и так есть). Аккуратно: проверить, где клиент кэширует права (lab-glue и т. п.).
|
||||||
|
|
||||||
|
## Phase B — Среднее (управление при масштабе)
|
||||||
|
- [ ] **B5. Группы прав (`group`).** Поле `group` в реестре («Контент»/«Класс»/«Геймификация»/«Профиль»/
|
||||||
|
«Библиотека») → секции в UI + «вкл/выкл всю группу».
|
||||||
|
- [ ] **B6. Массовое применение к классу / выбранным ученикам.** Через `class_members`: выставить право
|
||||||
|
пачке учеников (аналог матрицы/массовых операций content_access).
|
||||||
|
- [ ] **B7. Пресеты-профили прав.** Бандлы («Ограниченный ученик», «Ассистент учителя») — применить к
|
||||||
|
пользователю/классу одним кликом.
|
||||||
|
- [ ] **B8. Временные права (`expires_at`).** Колонка в `user_permissions` + авто-снятие в резолвере/кроне.
|
||||||
|
|
||||||
|
## Phase C — Крупное / архитектурное
|
||||||
|
- [ ] **C9. Кастомные роли.** Таблица ролей в БД + наборы дефолтов; реестр остаётся словарём ключей.
|
||||||
|
Кандидаты: методист/завуч, классрук/куратор, ассистент учителя, родитель (есть `parentAuth`).
|
||||||
|
- [ ] **C10. Делегирование учителю.** Часть студенческих прав — учителю в рамках его классов.
|
||||||
|
- [ ] **C11. Пер-классовый скоуп прав.** Право на уровне класса (свести к модели content_access). Большая работа.
|
||||||
|
|
||||||
|
## Порядок исполнения
|
||||||
|
A1 → A2 → A3 → A4 → B5 → B6 → B7 → B8 → C9 → C10 → C11. Phase C — отдельными ветками/планом (архитектура).
|
||||||
|
|
||||||
|
## Тесты/гочи
|
||||||
|
- Бэкенд-тесты через `backend/tests/setup.js` (харнесс монтирует /api/permissions). Прогон `node --test`.
|
||||||
|
- pre-commit гоняет полный backend-набор (baseline 3 Auth + флака «intro» в chemistry8-page под нагрузкой).
|
||||||
|
- ⛔ эмодзи; коммитить поимённо; fetch перед работой (активны параллельные сессии на master).
|
||||||
Reference in New Issue
Block a user