From dd1adc0c69ffbd4a81b7bd86f0d06c94c5587f3a Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sun, 17 May 2026 14:15:51 +0300 Subject: [PATCH 1/6] fix(perm): bump token_version on permission change (invalidates JWTs) setPermission / setUserPermission now bump token_version for affected users so cached JWTs lose access immediately instead of after expiry. Aligns with role-change pattern in adminController.updateRole. Both writes wrapped in db.transaction() so token_version is only bumped if the permission write itself succeeds. Also cleaned up inline require('../db/db') calls to use top-level db. Co-Authored-By: Claude Sonnet 4.6 --- .../src/controllers/permissionsController.js | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/backend/src/controllers/permissionsController.js b/backend/src/controllers/permissionsController.js index 68c8cf4..e953380 100644 --- a/backend/src/controllers/permissionsController.js +++ b/backend/src/controllers/permissionsController.js @@ -203,9 +203,15 @@ function setPermission(req, res) { return res.status(400).json({ error: 'Invalid role' }); if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === role)) return res.status(400).json({ error: 'Unknown permission' }); - db.prepare( - 'INSERT OR REPLACE INTO role_permissions (role, permission, enabled) VALUES (?, ?, ?)' - ).run(role, permission, enabled ? 1 : 0); + db.transaction(() => { + db.prepare( + 'INSERT OR REPLACE INTO role_permissions (role, permission, enabled) VALUES (?, ?, ?)' + ).run(role, permission, enabled ? 1 : 0); + // Invalidate JWTs for all users of that role so the change takes effect immediately + db.prepare( + 'UPDATE users SET token_version = token_version + 1 WHERE role = ?' + ).run(role); + })(); res.json({ ok: true }); } @@ -239,19 +245,19 @@ function getMyPermissions(req, res) { /* ── GET /api/permissions/users/:id ──────────────────────────────────── */ function getUserPermissions(req, res) { const uid = Number(req.params.id); - const target = require('../db/db').prepare('SELECT id, role FROM users WHERE id = ?').get(uid); + const target = db.prepare('SELECT id, role FROM users WHERE id = ?').get(uid); if (!target) return res.status(404).json({ error: 'User not found' }); seedDefaults(); // role-level values - const roleRows = require('../db/db').prepare( + const roleRows = db.prepare( 'SELECT permission, enabled FROM role_permissions WHERE role = ?' ).all(target.role); const roleMap = {}; for (const r of roleRows) roleMap[r.permission] = r.enabled === 1; // user-level overrides - const userRows = require('../db/db').prepare( + const userRows = db.prepare( 'SELECT permission, enabled FROM user_permissions WHERE user_id = ?' ).all(uid); const userMap = {}; @@ -274,13 +280,19 @@ function getUserPermissions(req, res) { function setUserPermission(req, res) { const uid = Number(req.params.id); const { permission, enabled } = req.body; - const target = require('../db/db').prepare('SELECT role FROM users WHERE id = ?').get(uid); + const target = db.prepare('SELECT role FROM users WHERE id = ?').get(uid); if (!target) return res.status(404).json({ error: 'User not found' }); if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === target.role)) return res.status(400).json({ error: 'Unknown permission for this role' }); - require('../db/db').prepare( - 'INSERT OR REPLACE INTO user_permissions (user_id, permission, enabled) VALUES (?, ?, ?)' - ).run(uid, permission, enabled ? 1 : 0); + db.transaction(() => { + db.prepare( + 'INSERT OR REPLACE INTO user_permissions (user_id, permission, enabled) VALUES (?, ?, ?)' + ).run(uid, permission, enabled ? 1 : 0); + // Invalidate existing JWT for this user immediately + db.prepare( + 'UPDATE users SET token_version = token_version + 1 WHERE id = ?' + ).run(uid); + })(); res.json({ ok: true }); } @@ -289,11 +301,11 @@ function resetUserPermissions(req, res) { const uid = Number(req.params.id); const { permission } = req.body; // optional: reset one key if (permission) { - require('../db/db').prepare( + db.prepare( 'DELETE FROM user_permissions WHERE user_id = ? AND permission = ?' ).run(uid, permission); } else { - require('../db/db').prepare('DELETE FROM user_permissions WHERE user_id = ?').run(uid); + db.prepare('DELETE FROM user_permissions WHERE user_id = ?').run(uid); } res.json({ ok: true }); } From 539d33df314632007a30e639ab2a65809c4a3c0e Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sun, 17 May 2026 14:16:45 +0300 Subject: [PATCH 2/6] feat(perm): audit log for permission + feature-flag changes Adds audit entries for: - permission.set (role-level change) - permission.user_set (per-user override) - permission.user_reset (clear user override) - feature.update (global feature flag toggle, per-key with old->new diff) Old value captured for feature.update for full diff trail. permissionsController: added audit import, wired audit() after each write. adminController.updateFeatures: replaced bulk audit with per-key entries capturing old value from app_settings before overwrite. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/controllers/adminController.js | 11 +++++++---- backend/src/controllers/permissionsController.js | 4 ++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index 1c71967..dab9692 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -327,13 +327,16 @@ function updateFeatures(req, res) { 'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom']; const updates = req.body; const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)"); - const changed = []; + const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?"); for (const [name, enabled] of Object.entries(updates)) { if (!allowed.includes(name)) continue; - stmt.run(`feature_${name}_enabled`, enabled ? '1' : '0'); - changed.push(`${name}=${enabled ? 'on' : 'off'}`); + const settingKey = `feature_${name}_enabled`; + const oldRow = getOld.get(settingKey); + const oldVal = oldRow ? oldRow.value : null; + const newVal = enabled ? '1' : '0'; + stmt.run(settingKey, newVal); + audit(req, 'feature.update', `feature:${name}`, `${oldVal} -> ${newVal}`); } - if (changed.length) audit(req, 'features.update', null, changed.join(', ')); res.json({ ok: true }); } diff --git a/backend/src/controllers/permissionsController.js b/backend/src/controllers/permissionsController.js index e953380..0ebd2bc 100644 --- a/backend/src/controllers/permissionsController.js +++ b/backend/src/controllers/permissionsController.js @@ -1,4 +1,5 @@ const db = require('../db/db'); +const { audit } = require('../utils/audit'); /* ── All known permissions ─────────────────────────────────────────────── */ const ALL_PERMISSIONS = [ @@ -212,6 +213,7 @@ function setPermission(req, res) { 'UPDATE users SET token_version = token_version + 1 WHERE role = ?' ).run(role); })(); + audit(req, 'permission.set', `role:${role}/${permission}`, `enabled=${enabled ? 1 : 0}`); res.json({ ok: true }); } @@ -293,6 +295,7 @@ function setUserPermission(req, res) { 'UPDATE users SET token_version = token_version + 1 WHERE id = ?' ).run(uid); })(); + audit(req, 'permission.user_set', `user:${uid}/${permission}`, `enabled=${enabled ? 1 : 0}`); res.json({ ok: true }); } @@ -307,6 +310,7 @@ function resetUserPermissions(req, res) { } else { db.prepare('DELETE FROM user_permissions WHERE user_id = ?').run(uid); } + audit(req, 'permission.user_reset', `user:${uid}`, permission || null); res.json({ ok: true }); } From 76883b569c6c3acefac9892a46273654b40dcb55 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sun, 17 May 2026 14:22:18 +0300 Subject: [PATCH 3/6] feat(perm): central permission registry + key validation in linter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/src/permissions/registry.js: single source of truth (PERMISSIONS map) with all 24 keys (16 teacher + 8 student, student keys also cover free_student). Exports isKnown(), listKeys(), byRole(), buildDefaultsMap(). - auth.js: PERM_DEFAULTS now sourced from registry.buildDefaultsMap(); new perm() helper validates key at registration time (crashes early on typos). requirePermission() unchanged — backward compat preserved. - permissionsController.js: ALL_PERMISSIONS now built from registry.byRole(); inline 24-entry array removed. API response shape unchanged. - check-route-auth.js: validates every requirePermission/perm call key against registry; lists unknown keys as errors before exit. perm() added to GUARDS list so it counts as route protection. Discrepancy noted: auth.js had free_student with same 8 keys as student; permissionsController never seeded free_student rows. Registry documents this via roles:[] array; buildDefaultsMap() correctly covers free_student. Co-Authored-By: Claude Sonnet 4.6 --- backend/scripts/check-route-auth.js | 33 ++- .../src/controllers/permissionsController.js | 177 +--------------- backend/src/middleware/auth.js | 62 ++---- backend/src/permissions/registry.js | 192 ++++++++++++++++++ 4 files changed, 247 insertions(+), 217 deletions(-) create mode 100644 backend/src/permissions/registry.js diff --git a/backend/scripts/check-route-auth.js b/backend/scripts/check-route-auth.js index a032e98..fd60560 100644 --- a/backend/scripts/check-route-auth.js +++ b/backend/scripts/check-route-auth.js @@ -32,8 +32,9 @@ */ 'use strict'; -const fs = require('fs'); -const path = require('path'); +const fs = require('fs'); +const path = require('path'); +const registry = require('../src/permissions/registry'); const ROUTES_DIR = path.join(__dirname, '../src/routes'); @@ -42,6 +43,7 @@ const GUARDS = [ 'requireOwnership', 'requireRole', 'requirePermission', + 'perm', // ergonomic alias from auth.js that validates key at registration time 'parentAuth', 'authMiddleware', 'requireAuth', @@ -128,6 +130,33 @@ function main() { console.log(`Route auth check: ${total} :id-routes total, ${unprotected} unprotected (baseline: ${BASELINE})`); + /* ── Permission key validation ───────────────────────────────────────── */ + // Scan all route files for requirePermission('X') and perm('X') calls. + // Verify each key exists in the central registry. + const permKeyRe = /(?:requirePermission|perm)\(\s*['"`]([^'"`]+)['"`]/g; + const unknownPermKeys = []; + for (const file of files) { + const src = fs.readFileSync(file, 'utf8'); + let m; + permKeyRe.lastIndex = 0; + while ((m = permKeyRe.exec(src)) !== null) { + const key = m[1]; + if (!registry.isKnown(key)) { + unknownPermKeys.push({ file: path.basename(file), key }); + } + } + } + if (unknownPermKeys.length > 0) { + console.error('\nFAIL: requirePermission/perm calls with keys not in registry:'); + for (const { file, key } of unknownPermKeys) { + console.error(` ${file}: "${key}"`); + } + console.error('Add missing keys to backend/src/permissions/registry.js'); + process.exit(1); + } else { + console.log('Permission key check: all requirePermission/perm keys are registered.'); + } + if (unprotected > BASELINE) { console.error(`\nFAIL: ${unprotected - BASELINE} new unprotected route(s) added above baseline.`); console.error('Add requireOwnership/requireRole/requirePermission, or mark intentional:'); diff --git a/backend/src/controllers/permissionsController.js b/backend/src/controllers/permissionsController.js index 0ebd2bc..403fc7c 100644 --- a/backend/src/controllers/permissionsController.js +++ b/backend/src/controllers/permissionsController.js @@ -1,178 +1,13 @@ const db = require('../db/db'); const { audit } = require('../utils/audit'); +const registry = require('../permissions/registry'); -/* ── All known permissions ─────────────────────────────────────────────── */ +/* ── All known permissions — sourced from central registry ────────────── */ +// Only teacher and student entries are exposed to the admin UI. +// free_student shares the same keys as student (handled in auth.js fallback). const ALL_PERMISSIONS = [ - /* ── Teacher ── */ - { - key: 'questions.manage', - role: 'teacher', - label: 'Управление вопросами', - desc: 'Создавать, редактировать и копировать вопросы в банке', - default: 0, - }, - { - key: 'questions.delete', - role: 'teacher', - label: 'Удалять вопросы', - desc: 'Удалять вопросы из банка (требует "Управление вопросами")', - default: 0, - }, - { - key: 'students.invite', - role: 'teacher', - label: 'Регистрировать учеников', - desc: 'Создавать новые аккаунты учеников напрямую из панели', - default: 0, - }, - { - key: 'sessions.reset', - role: 'teacher', - label: 'Сброс попыток', - desc: 'Сбрасывать прохождение теста ученика в своём классе', - default: 1, - }, - { - key: 'results.export', - role: 'teacher', - label: 'Экспорт результатов', - desc: 'Выгружать результаты и оценки класса в CSV', - default: 1, - }, - { - key: 'classes.manage', - role: 'teacher', - label: 'Управление классами', - desc: 'Создавать, редактировать и удалять свои классы', - default: 1, - }, - { - key: 'library.upload', - role: 'teacher', - label: 'Загрузка файлов', - desc: 'Загружать файлы в библиотеку', - default: 1, - }, - { - key: 'library.folders', - role: 'teacher', - label: 'Управление папками', - desc: 'Создавать папки и настраивать доступ к ним', - default: 1, - }, - { - key: 'schedule.manage', - role: 'teacher', - label: 'Дедлайны заданий', - desc: 'Устанавливать дедлайны и временные окна для заданий', - default: 1, - }, - { - key: 'announcements.send', - role: 'teacher', - label: 'Объявления', - desc: 'Публиковать объявления в своих классах', - default: 1, - }, - { - key: 'templates.manage', - role: 'teacher', - label: 'Управление шаблонами', - desc: 'Создавать и использовать шаблоны курсов и уроков', - default: 1, - }, - { - key: 'templates.public', - role: 'teacher', - label: 'Публикация шаблонов', - desc: 'Делать свои шаблоны публичными для всех учителей', - default: 0, - }, - { - key: 'courses.manage', - role: 'teacher', - label: 'Управление курсами', - desc: 'Создавать и редактировать теоретические курсы и уроки', - default: 1, - }, - { - key: 'courses.interactive', - role: 'teacher', - label: 'Интерактивные блоки', - desc: 'Добавлять интерактивные задания в уроки (сопоставление, пропуски, порядок)', - default: 1, - }, - { - key: 'shop.manage', - role: 'teacher', - label: 'Управление магазином', - desc: 'Создавать и редактировать товары в магазине наград', - default: 0, - }, - { - key: 'gamification.manage', - role: 'teacher', - label: 'Управление геймификацией', - desc: 'Начислять XP/монеты ученикам, управлять достижениями', - default: 0, - }, - /* ── Student ── */ - { - key: 'tests.free', - role: 'student', - label: 'Свободные тесты', - desc: 'Проходить тесты без задания (по предмету / случайно)', - default: 1, - }, - { - key: 'board.post', - role: 'student', - label: 'Реакции на доске', - desc: 'Ставить реакции на задания на доске', - default: 1, - }, - { - key: 'profile.edit', - role: 'student', - label: 'Редактирование профиля', - desc: 'Изменять своё имя и пароль', - default: 1, - }, - { - key: 'shop.purchase', - role: 'student', - label: 'Покупки в магазине', - desc: 'Покупать предметы в магазине наград за монеты', - default: 1, - }, - { - key: 'gamification.challenges', - role: 'student', - label: 'Испытания недели', - desc: 'Участвовать в еженедельных испытаниях и получать награды', - default: 1, - }, - { - key: 'theory.access', - role: 'student', - label: 'Доступ к теории', - desc: 'Просматривать теоретические курсы и уроки', - default: 1, - }, - { - key: 'simulations.access', - role: 'student', - label: 'Доступ к симуляциям', - desc: 'Открывать лабораторию с физическими, химическими и биологическими симуляциями', - default: 1, - }, - { - key: 'simulations.quiz', - role: 'student', - label: 'Задания в симуляциях', - desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)', - default: 1, - }, + ...registry.byRole('teacher'), + ...registry.byRole('student'), ]; /* ── Seed defaults once per startup ───────────────────────────────────── */ diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 21b0c05..6395ccd 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -1,47 +1,9 @@ -const jwt = require('jsonwebtoken'); -const db = require('../db/db'); +const jwt = require('jsonwebtoken'); +const db = require('../db/db'); +const registry = require('../permissions/registry'); -/* ── Default values for role_permissions (mirrors permissionsController) ── */ -const PERM_DEFAULTS = { - teacher: { - 'questions.manage': false, - 'questions.delete': false, - 'students.invite': false, - 'sessions.reset': true, - 'results.export': true, - 'classes.manage': true, - 'library.upload': true, - 'library.folders': true, - 'schedule.manage': true, - 'announcements.send': true, - 'templates.manage': true, - 'templates.public': false, - 'courses.manage': true, - 'courses.interactive': true, - 'shop.manage': false, - 'gamification.manage': false, - }, - student: { - 'tests.free': true, - 'board.post': true, - 'profile.edit': true, - 'shop.purchase': true, - 'gamification.challenges': true, - 'theory.access': true, - 'simulations.access': true, - 'simulations.quiz': true, - }, - free_student: { - 'tests.free': true, - 'board.post': true, - 'profile.edit': true, - 'shop.purchase': true, - 'gamification.challenges': true, - 'theory.access': true, - 'simulations.access': true, - 'simulations.quiz': true, - }, -}; +/* ── Default values for role_permissions — sourced from central registry ── */ +const PERM_DEFAULTS = registry.buildDefaultsMap(); function authMiddleware(req, res, next) { const header = req.headers.authorization; @@ -134,6 +96,18 @@ function parentAuth(req, res, next) { /* Alias: requireAuth = authMiddleware */ const requireAuth = authMiddleware; +/** + * perm(key) — ergonomic alias for requirePermission(key). + * Throws at module-load time if `key` is not in the registry, + * so typos are caught at startup rather than at runtime. + */ +function perm(key) { + if (!registry.isKnown(key)) { + throw new Error(`[auth] Unknown permission key: "${key}". Add it to backend/src/permissions/registry.js`); + } + return requirePermission(key); +} + /* optionalAuth: попытаться установить req.user, но не блокировать при отсутствии токена */ function optionalAuth(req, res, next) { const header = req.headers.authorization || ''; @@ -151,4 +125,4 @@ function optionalAuth(req, res, next) { next(); } -module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, parentAuth }; +module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, perm, parentAuth }; diff --git a/backend/src/permissions/registry.js b/backend/src/permissions/registry.js new file mode 100644 index 0000000..df36290 --- /dev/null +++ b/backend/src/permissions/registry.js @@ -0,0 +1,192 @@ +'use strict'; + +/** + * Single source of truth for all granular permissions. + * Keys here MUST match what controllers/routes use in requirePermission(...). + * + * Shape: + * : { + * role: 'teacher' | 'student', // primary role for admin UI grouping + * roles: string[], // all roles this key applies to + * default: 0 | 1, // PERM_DEFAULTS value + * label: 'Short label for admin UI', + * desc: 'Russian description for admin UI' + * } + * + * DISCREPANCY NOTE (2026-05-17): + * auth.js PERM_DEFAULTS includes a 'free_student' role with the same 8 + * permission keys as 'student' (identical key strings, identical defaults). + * permissionsController.js ALL_PERMISSIONS does NOT list free_student entries + * (seedDefaults/admin UI only handles teacher and student). + * Resolution: each student-role key carries roles:['student','free_student'] + * so that requirePermission() fallback works correctly for free_student users. + * The admin UI behaviour (filtering to teacher/student only) is preserved — + * permissionsController reads from registry.byRole('teacher') and + * registry.byRole('student'). + */ + +const PERMISSIONS = { + /* ── Teacher ─────────────────────────────────────────────────────────── */ + 'questions.manage': { + role: 'teacher', roles: ['teacher'], default: 0, + label: 'Управление вопросами', + desc: 'Создавать, редактировать и копировать вопросы в банке', + }, + 'questions.delete': { + role: 'teacher', roles: ['teacher'], default: 0, + label: 'Удалять вопросы', + desc: 'Удалять вопросы из банка (требует "Управление вопросами")', + }, + 'students.invite': { + role: 'teacher', roles: ['teacher'], default: 0, + label: 'Регистрировать учеников', + desc: 'Создавать новые аккаунты учеников напрямую из панели', + }, + 'sessions.reset': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Сброс попыток', + desc: 'Сбрасывать прохождение теста ученика в своём классе', + }, + 'results.export': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Экспорт результатов', + desc: 'Выгружать результаты и оценки класса в CSV', + }, + 'classes.manage': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Управление классами', + desc: 'Создавать, редактировать и удалять свои классы', + }, + 'library.upload': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Загрузка файлов', + desc: 'Загружать файлы в библиотеку', + }, + 'library.folders': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Управление папками', + desc: 'Создавать папки и настраивать доступ к ним', + }, + 'schedule.manage': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Дедлайны заданий', + desc: 'Устанавливать дедлайны и временные окна для заданий', + }, + 'announcements.send': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Объявления', + desc: 'Публиковать объявления в своих классах', + }, + 'templates.manage': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Управление шаблонами', + desc: 'Создавать и использовать шаблоны курсов и уроков', + }, + 'templates.public': { + role: 'teacher', roles: ['teacher'], default: 0, + label: 'Публикация шаблонов', + desc: 'Делать свои шаблоны публичными для всех учителей', + }, + 'courses.manage': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Управление курсами', + desc: 'Создавать и редактировать теоретические курсы и уроки', + }, + 'courses.interactive': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Интерактивные блоки', + desc: 'Добавлять интерактивные задания в уроки (сопоставление, пропуски, порядок)', + }, + 'shop.manage': { + role: 'teacher', roles: ['teacher'], default: 0, + label: 'Управление магазином', + desc: 'Создавать и редактировать товары в магазине наград', + }, + 'gamification.manage': { + role: 'teacher', roles: ['teacher'], default: 0, + label: 'Управление геймификацией', + desc: 'Начислять XP/монеты ученикам, управлять достижениями', + }, + + /* ── Student (also applies to free_student — same keys, same defaults) ── */ + 'tests.free': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Свободные тесты', + desc: 'Проходить тесты без задания (по предмету / случайно)', + }, + 'board.post': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Реакции на доске', + desc: 'Ставить реакции на задания на доске', + }, + 'profile.edit': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Редактирование профиля', + desc: 'Изменять своё имя и пароль', + }, + 'shop.purchase': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Покупки в магазине', + desc: 'Покупать предметы в магазине наград за монеты', + }, + 'gamification.challenges': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Испытания недели', + desc: 'Участвовать в еженедельных испытаниях и получать награды', + }, + 'theory.access': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Доступ к теории', + desc: 'Просматривать теоретические курсы и уроки', + }, + 'simulations.access': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Доступ к симуляциям', + desc: 'Открывать лабораторию с физическими, химическими и биологическими симуляциями', + }, + 'simulations.quiz': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Задания в симуляциях', + desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)', + }, +}; + +/** + * Check whether a given permission key exists in the registry. + * Used by perm() helper in auth.js to fail early on typos. + */ +function isKnown(key) { + return Object.prototype.hasOwnProperty.call(PERMISSIONS, key); +} + +/** Return all registered permission keys. */ +function listKeys() { + return Object.keys(PERMISSIONS); +} + +/** + * Return all entries whose primary role matches. + * Returns objects shaped like ALL_PERMISSIONS entries (key, role, default, label, desc). + */ +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 })); +} + +/** + * Build a PERM_DEFAULTS-style lookup: { role: { key: bool } } + * Used by auth.js requirePermission fallback. + */ +function buildDefaultsMap() { + const map = {}; + for (const [key, v] of Object.entries(PERMISSIONS)) { + for (const r of v.roles) { + if (!map[r]) map[r] = {}; + map[r][key] = v.default === 1; + } + } + return map; +} + +module.exports = { PERMISSIONS, isKnown, listKeys, byRole, buildDefaultsMap }; From 3e187a94c017e0633d503152bc8c756f073d837b Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sun, 17 May 2026 14:25:03 +0300 Subject: [PATCH 4/6] fix(perm): bump token_version on resetUserPermissions too Reset can downgrade effective access (override=1 vs role default=0), so the user's JWT must be invalidated alongside the DELETE. Wrapped in db.transaction for atomicity. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/permissionsController.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/backend/src/controllers/permissionsController.js b/backend/src/controllers/permissionsController.js index 403fc7c..7906b26 100644 --- a/backend/src/controllers/permissionsController.js +++ b/backend/src/controllers/permissionsController.js @@ -138,13 +138,18 @@ function setUserPermission(req, res) { function resetUserPermissions(req, res) { const uid = Number(req.params.id); const { permission } = req.body; // optional: reset one key - if (permission) { - db.prepare( - 'DELETE FROM user_permissions WHERE user_id = ? AND permission = ?' - ).run(uid, permission); - } else { - db.prepare('DELETE FROM user_permissions WHERE user_id = ?').run(uid); - } + db.transaction(() => { + if (permission) { + db.prepare( + 'DELETE FROM user_permissions WHERE user_id = ? AND permission = ?' + ).run(uid, permission); + } else { + db.prepare('DELETE FROM user_permissions WHERE user_id = ?').run(uid); + } + // Bump token_version so the user's JWT picks up the new effective permissions + // immediately (could be a downgrade if override was =1 and role default is =0). + db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?').run(uid); + })(); audit(req, 'permission.user_reset', `user:${uid}`, permission || null); res.json({ ok: true }); } From 19c16bdfe8b3798c97b53f3081fb9632c9cc60df Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sun, 17 May 2026 14:35:29 +0300 Subject: [PATCH 5/6] feat(perm): block API endpoints for globally-disabled features (B-lite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds backend/src/middleware/features.js with requireFeature(name) that returns 404 when app_settings.feature__enabled='0'. Wired on 8 routes: - /api/pet (pet) - /api/collection (collection) - /api/red-book (red_book) - /api/flashcards (flashcards) - /api/knowledge-map (knowledge_map) - /api/biochem (biochem) - /api/games/hangman/* (hangman, per-route inside games router) - /api/games/crossword/* (crossword, per-route) Scope: GLOBAL only. Per-class disable (classes.features JSON) and the free_student role overlay remain UI-gated. Add user-aware merge later if needed (extract logic from /api/features endpoint into shared helper). Not gated (intentional, core teacher tools): board, classroom, live_quiz. Smoke: pet disabled → 404; enabled → 401 (auth-required passthrough). Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/middleware/features.js | 41 ++++++++++++++++++++++++++++++ backend/src/routes/games.js | 12 ++++++--- backend/src/server.js | 13 +++++----- 3 files changed, 56 insertions(+), 10 deletions(-) create mode 100644 backend/src/middleware/features.js diff --git a/backend/src/middleware/features.js b/backend/src/middleware/features.js new file mode 100644 index 0000000..cc7532d --- /dev/null +++ b/backend/src/middleware/features.js @@ -0,0 +1,41 @@ +'use strict'; + +/** + * Feature-flag middleware: blocks the request when the named feature is + * globally disabled in app_settings. + * + * Scope (B-lite): GLOBAL only — checks the app_settings.feature__enabled + * row that admin toggles in the admin panel. Per-class disable + * (classes.features JSON) and the free_student role-level overlay + * (app_settings.free_student_features) are NOT checked here — those layers + * remain UI-gated in /api/features. A student bypassing the UI gate via + * direct curl is the documented limitation; can be tightened later by + * extracting the merge logic from server.js → /api/features into a shared + * helper. + * + * Default: missing key = enabled (opt-in disable model). + * + * Response: 404 on disabled feature (intentional — don't leak endpoint shape). + * + * Usage: + * app.use('/api/pet', requireFeature('pet'), petRoutes); + * router.get('/hangman/word', requireFeature('hangman'), authMiddleware, handler); + */ +const db = require('../db/db'); + +const _stmt = db.prepare( + "SELECT value FROM app_settings WHERE key = ?" +); + +function requireFeature(name) { + const settingKey = `feature_${name}_enabled`; + return (req, res, next) => { + const row = _stmt.get(settingKey); + if (row && row.value === '0') { + return res.status(404).json({ error: 'Feature disabled' }); + } + next(); + }; +} + +module.exports = { requireFeature }; diff --git a/backend/src/routes/games.js b/backend/src/routes/games.js index 7bae763..b22564d 100644 --- a/backend/src/routes/games.js +++ b/backend/src/routes/games.js @@ -1,10 +1,14 @@ const router = require('express').Router(); const { authMiddleware } = require('../middleware/auth'); +const { requireFeature } = require('../middleware/features'); const c = require('../controllers/gamesController'); -router.get('/hangman/word', authMiddleware, c.hangmanWord); -router.post('/hangman/complete', authMiddleware, c.hangmanComplete); -router.get('/crossword/generate', authMiddleware, c.crosswordGenerate); -router.post('/crossword/complete', authMiddleware, c.crosswordComplete); +const hangman = requireFeature('hangman'); +const crossword = requireFeature('crossword'); + +router.get('/hangman/word', hangman, authMiddleware, c.hangmanWord); +router.post('/hangman/complete', hangman, authMiddleware, c.hangmanComplete); +router.get('/crossword/generate', crossword, authMiddleware, c.crosswordGenerate); +router.post('/crossword/complete', crossword, authMiddleware, c.crosswordComplete); module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index 816fb58..325f79c 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -130,6 +130,7 @@ app.use(express.json({ limit: '1mb' })); /* ── Global API rate limit ── */ const rateLimit = require('./middleware/rateLimit'); +const { requireFeature } = require('./middleware/features'); // Classroom real-time endpoints (cursor, stroke-preview) fire ~10/s per user — higher limit app.use('/api/classroom', rateLimit({ windowMs: 60_000, max: 6000, message: 'Слишком много запросов' })); app.use('/api', rateLimit({ windowMs: 60_000, max: 600, message: 'Слишком много запросов, подождите минуту' })); @@ -154,7 +155,7 @@ app.use('/api/shop', shopRoutes); app.use('/api/templates', templateRoutes); app.use('/api/bookmarks', bookmarkRoutes); app.use('/api/search', searchRoutes); -app.use('/api/flashcards', flashcardRoutes); +app.use('/api/flashcards', requireFeature('flashcards'), flashcardRoutes); app.use('/api/settings', settingsRoutes); app.use('/api/preferences', require('./routes/preferences')); app.use('/api/avatar', require('./routes/avatar')); @@ -163,11 +164,11 @@ app.use('/api/live', liveRoutes); app.use('/api/classroom/guest', guestClassroomRoutes); // public — MUST be before /api/classroom app.use('/api/classroom', classroomRoutes); app.use('/api/games', gamesRoutes); -app.use('/api/knowledge-map', knowledgeMapRoutes); -app.use('/api/pet', petRoutes); -app.use('/api/collection', collectionRoutes); -app.use('/api/red-book', redBookRoutes); -app.use('/api/biochem', require('./routes/biochem')); +app.use('/api/knowledge-map', requireFeature('knowledge_map'), knowledgeMapRoutes); +app.use('/api/pet', requireFeature('pet'), petRoutes); +app.use('/api/collection', requireFeature('collection'), collectionRoutes); +app.use('/api/red-book', requireFeature('red_book'), redBookRoutes); +app.use('/api/biochem', requireFeature('biochem'), require('./routes/biochem')); app.use('/api/parent', parentRoutes); app.use('/api/exam9', exam9Routes); app.use('/api/textbooks', textbookRoutes); From 7eea33a1351b8ec77b4bafb0b9609493fdfefc62 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sun, 17 May 2026 14:43:49 +0300 Subject: [PATCH 6/6] feat(perm-ui): P0 usability improvements (search, default-dot, confirm-critical, wording) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry.js: добавлен флаг requireConfirmOff для 7 критичных прав (questions.manage, classes.manage, library.upload, courses.manage, sessions.reset, theory.access, simulations.access); byRole() теперь возвращает это поле - admin.html: subtitle в модале прав — «учителя» → «пользователя»; tooltip на кнопке «Сбросить всё по умолчанию»; поле поиска над сеткой прав; CSS .perm-modified-dot (amber, 8px) - admin.js: badge «Инд.» → «Индивидуально» (font-size 11px); renderPermissions() рисует .perm-modified-dot когда значение отличается от registry default; togglePermission() показывает LS.confirm перед выключением критичных прав; window.filterPermissions() скрывает карточки и role-блоки по поисковому запросу Co-Authored-By: Claude Sonnet 4.6 --- backend/src/permissions/registry.js | 9 +++++++- frontend/admin.html | 17 ++++++++++++-- frontend/js/admin/admin.js | 36 +++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/backend/src/permissions/registry.js b/backend/src/permissions/registry.js index df36290..8ccde6f 100644 --- a/backend/src/permissions/registry.js +++ b/backend/src/permissions/registry.js @@ -31,6 +31,7 @@ const PERMISSIONS = { role: 'teacher', roles: ['teacher'], default: 0, label: 'Управление вопросами', desc: 'Создавать, редактировать и копировать вопросы в банке', + requireConfirmOff: true, }, 'questions.delete': { role: 'teacher', roles: ['teacher'], default: 0, @@ -46,6 +47,7 @@ const PERMISSIONS = { role: 'teacher', roles: ['teacher'], default: 1, label: 'Сброс попыток', desc: 'Сбрасывать прохождение теста ученика в своём классе', + requireConfirmOff: true, }, 'results.export': { role: 'teacher', roles: ['teacher'], default: 1, @@ -56,11 +58,13 @@ const PERMISSIONS = { role: 'teacher', roles: ['teacher'], default: 1, label: 'Управление классами', desc: 'Создавать, редактировать и удалять свои классы', + requireConfirmOff: true, }, 'library.upload': { role: 'teacher', roles: ['teacher'], default: 1, label: 'Загрузка файлов', desc: 'Загружать файлы в библиотеку', + requireConfirmOff: true, }, 'library.folders': { role: 'teacher', roles: ['teacher'], default: 1, @@ -91,6 +95,7 @@ const PERMISSIONS = { role: 'teacher', roles: ['teacher'], default: 1, label: 'Управление курсами', desc: 'Создавать и редактировать теоретические курсы и уроки', + requireConfirmOff: true, }, 'courses.interactive': { role: 'teacher', roles: ['teacher'], default: 1, @@ -138,11 +143,13 @@ const PERMISSIONS = { role: 'student', roles: ['student', 'free_student'], default: 1, label: 'Доступ к теории', desc: 'Просматривать теоретические курсы и уроки', + requireConfirmOff: true, }, 'simulations.access': { role: 'student', roles: ['student', 'free_student'], default: 1, label: 'Доступ к симуляциям', desc: 'Открывать лабораторию с физическими, химическими и биологическими симуляциями', + requireConfirmOff: true, }, 'simulations.quiz': { role: 'student', roles: ['student', 'free_student'], default: 1, @@ -171,7 +178,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 })); + .map(([key, v]) => ({ key, role: v.role, default: v.default, label: v.label, desc: v.desc, requireConfirmOff: !!v.requireConfirmOff })); } /** diff --git a/frontend/admin.html b/frontend/admin.html index 816e69b..b82a23a 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -261,6 +261,12 @@ .perm-toggle input:checked ~ .perm-track { background: var(--green, #06d6a0); } .perm-toggle input:checked ~ .perm-thumb { transform: translateX(20px); } .perm-toggle input:focus-visible ~ .perm-track { outline: 2px solid var(--violet); } + /* dot shown when a role-level perm differs from its registry default */ + .perm-modified-dot { + display: inline-block; width: 8px; height: 8px; border-radius: 50%; + background: var(--amber, #FFB347); flex-shrink: 0; + vertical-align: middle; margin-left: 6px; + } /* toolbar */ .t-toolbar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 24px; } @@ -1223,6 +1229,12 @@

Настройте, что могут делать учителя и ученики. Администраторы имеют все права всегда.

+
+ +
+
Учитель @@ -1563,10 +1575,11 @@
Права пользователя
-

Индивидуальные настройки переопределяют права роли для этого учителя.

+

Индивидуальные настройки переопределяют права роли для этого пользователя.

- diff --git a/frontend/js/admin/admin.js b/frontend/js/admin/admin.js index 5b68719..57db466 100644 --- a/frontend/js/admin/admin.js +++ b/frontend/js/admin/admin.js @@ -2144,7 +2144,7 @@ const hasOverride = p.userVal !== undefined; const checked = p.effective; const badge = hasOverride - ? `Инд.` + ? `Индивидуально` : `По роли`; const resetBtn = hasOverride ? `