From d5fbd0168edf23ef55b253cb307a3826208ec958 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Mon, 22 Jun 2026 17:31:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(permissions):=20+10=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=20=D1=80=D0=BE=D0=BB=D0=B5=D0=B9=20=D1=81=20=D1=8D=D0=BD?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D1=81=D0=BE=D0=BC=20(=D0=94=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=83=D0=BF=20=C2=B7=20=D1=80=D0=BE=D0=BB=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реестр (registry.js) пополнен правами, которыми раньше нельзя было управлять: • Учитель: classroom.host (онлайн-уроки), livequiz.host (живые викторины), simbuilder.use (конструктор симуляций), flashcards.manage (общие колоды). • Ученик: homework.submit (сдача ДЗ), materials.save («Мои материалы»), assistant.use (ИИ-ассистент), games.play (учебные игры), flashcards.access / exam.access (доступ к разделам). Все default=1 → текущее поведение сохранено; админ может выключить по роли/классу/юзеру. Энфорс на роутах: учительские — requirePermission (роуты уже teacher-only); ученические на ОБЩИХ роутах (assistant/materials/games/flashcards/exam-prep) — новый requirePermissionForStudents(key) (учитель/админ проходят всегда, проверка только ученику — иначе isEnabled=false сломал бы учителя). PERM_DEFAULTS строится из реестра → фолбэк до сидирования = enabled, никто не блокируется. Группы UI — существующие (новых ярлыков нет). seedDefaults авто-сидит новые ключи на чтении. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/middleware/auth.js | 18 ++++++++- backend/src/permissions/registry.js | 59 +++++++++++++++++++++++++++++ backend/src/routes/assistant.js | 6 +-- backend/src/routes/classroom.js | 4 +- backend/src/routes/customSims.js | 6 +-- backend/src/routes/exam-prep.js | 5 ++- backend/src/routes/flashcards.js | 9 +++-- backend/src/routes/games.js | 12 +++--- backend/src/routes/live.js | 4 +- backend/src/routes/materials.js | 5 ++- backend/src/routes/submissions.js | 6 +-- 11 files changed, 109 insertions(+), 25 deletions(-) diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 56760c5..aad0792 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -119,6 +119,22 @@ function parentAuth(req, res, next) { } } +/** + * requirePermissionForStudents(key) — применяет проверку права ТОЛЬКО к ролям + * ученика (student/free_student); учитель и админ проходят всегда. + * Нужно для роутов, которыми пользуются и учителя, и ученики (ассистент, + * материалы, игры, флеш-карты, exam-prep): ученическое право не должно ломать + * доступ учителю (у учителя нет записи по ключу → isEnabled вернул бы false). + */ +function requirePermissionForStudents(key) { + const guard = requirePermission(key); + return (req, res, next) => { + const r = req.user?.role; + if (r === 'student' || r === 'free_student') return guard(req, res, next); + return next(); + }; +} + /* Alias: requireAuth = authMiddleware */ const requireAuth = authMiddleware; @@ -151,4 +167,4 @@ function optionalAuth(req, res, next) { next(); } -module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, perm, parentAuth, effectiveRoles }; +module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, requirePermissionForStudents, perm, parentAuth, effectiveRoles }; diff --git a/backend/src/permissions/registry.js b/backend/src/permissions/registry.js index 0b10acd..e7709da 100644 --- a/backend/src/permissions/registry.js +++ b/backend/src/permissions/registry.js @@ -115,6 +115,28 @@ const PERMISSIONS = { label: 'Управление геймификацией', desc: 'Начислять XP/монеты ученикам, управлять достижениями', }, + 'classroom.host': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Вести онлайн-уроки', + desc: 'Запускать синхронные онлайн-уроки (classroom) с доской для класса', + requireConfirmOff: true, + }, + 'livequiz.host': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Запускать живые викторины', + desc: 'Создавать и проводить синхронные викторины в реальном времени', + }, + 'simbuilder.use': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Конструктор симуляций', + desc: 'Создавать и редактировать собственные интерактивные симуляции', + requireConfirmOff: true, + }, + 'flashcards.manage': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Общие колоды флеш-карт', + desc: 'Создавать и раздавать общие колоды флеш-карт классам', + }, /* ── Student (also applies to free_student — same keys, same defaults) ── */ 'tests.free': { @@ -160,6 +182,38 @@ const PERMISSIONS = { desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)', requires: ['simulations.access'], }, + 'homework.submit': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Сдавать домашние задания', + desc: 'Загружать работы и пересдавать домашние задания', + }, + 'materials.save': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Сохранять материалы', + desc: 'Сохранять доску/заметки/рисунки в раздел «Мои материалы»', + }, + 'assistant.use': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'ИИ-ассистент', + desc: 'Задавать вопросы ИИ-ассистенту «Квантик»', + }, + 'flashcards.access': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Раздел флеш-карт доступен роли', + desc: 'Включает раздел флеш-карт для роли. Какие именно колоды видны — настраивается по классам в «Доступ · контент»', + requireConfirmOff: true, + }, + 'exam.access': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Подготовка к экзаменам доступна роли', + desc: 'Включает разделы подготовки к экзаменам/ЦТ для роли. Какие именно модули видны — настраивается в «Доступ · контент»', + requireConfirmOff: true, + }, + 'games.play': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Учебные игры', + desc: 'Играть в учебные мини-игры (Виселица, Кроссворд)', + }, }; /* Группы для секций в админ-UI (один источник; byRole проставляет group). */ @@ -169,15 +223,20 @@ const GROUP = { 'students.invite': 'Класс и ученики', 'sessions.reset': 'Класс и ученики', 'results.export': 'Класс и ученики', 'classes.manage': 'Класс и ученики', 'schedule.manage': 'Класс и ученики', 'announcements.send': 'Класс и ученики', + 'classroom.host': 'Класс и ученики', 'livequiz.host': 'Класс и ученики', 'library.upload': 'Библиотека', 'library.folders': 'Библиотека', 'templates.manage': 'Курсы и шаблоны', 'templates.public': 'Курсы и шаблоны', 'courses.manage': 'Курсы и шаблоны', 'courses.interactive': 'Курсы и шаблоны', + 'simbuilder.use': 'Курсы и шаблоны', 'flashcards.manage': 'Курсы и шаблоны', 'shop.manage': 'Геймификация', 'gamification.manage': 'Геймификация', // student 'tests.free': 'Тесты и активность', 'board.post': 'Тесты и активность', + 'homework.submit': 'Тесты и активность', 'materials.save': 'Тесты и активность', + 'assistant.use': 'Тесты и активность', 'games.play': 'Тесты и активность', 'profile.edit': 'Профиль', 'shop.purchase': 'Геймификация', 'gamification.challenges': 'Геймификация', 'theory.access': 'Контент', 'simulations.access': 'Контент', 'simulations.quiz': 'Контент', + 'flashcards.access': 'Контент', 'exam.access': 'Контент', }; /** diff --git a/backend/src/routes/assistant.js b/backend/src/routes/assistant.js index acf6c69..ddfb4f6 100644 --- a/backend/src/routes/assistant.js +++ b/backend/src/routes/assistant.js @@ -2,7 +2,7 @@ /* Квантик-ассистент. Все маршруты — под авторизацией (router-level), фича-гейт * 'pet' навешивается при монтировании в server.js. */ const router = require('express').Router(); -const { authMiddleware, requireRole } = require('../middleware/auth'); +const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth'); const rateLimit = require('../middleware/rateLimit'); const ctrl = require('../controllers/assistantController'); @@ -16,8 +16,8 @@ router.get('/context', ctrl.getContext); router.post('/seen', ctrl.markSeen); router.post('/dismiss', ctrl.dismiss); router.patch('/settings', ctrl.setSettings); -router.post('/ask', askLimiter, ctrl.ask); -router.post('/flashcards', fcLimiter, ctrl.flashcardsFromText); +router.post('/ask', requirePermissionForStudents('assistant.use'), askLimiter, ctrl.ask); +router.post('/flashcards', requirePermissionForStudents('assistant.use'), fcLimiter, ctrl.flashcardsFromText); router.post('/feedback', ctrl.feedback); router.get('/memory', ctrl.getMemory); router.delete('/memory', ctrl.clearMemory); diff --git a/backend/src/routes/classroom.js b/backend/src/routes/classroom.js index c8a5ce5..4625ecd 100644 --- a/backend/src/routes/classroom.js +++ b/backend/src/routes/classroom.js @@ -2,7 +2,7 @@ const router = require('express').Router(); const multer = require('multer'); const path = require('path'); const crypto = require('crypto'); -const { authMiddleware, requireRole } = require('../middleware/auth'); +const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth'); const rateLimit = require('../middleware/rateLimit'); const c = require('../controllers/classroomController'); @@ -47,7 +47,7 @@ router.get('/my/history', ...auth, c.getMyHistory); router.get('/class/:classId/history', ...auth, c.getClassHistory); // Session lifecycle -router.post('/', ...teacher, c.createSession); +router.post('/', ...teacher, requirePermission('classroom.host'), c.createSession); router.get('/online-students', ...teacher, c.getOnlineStudents); router.get('/my/session', ...auth, c.getMySession); router.get('/class/:classId/active', ...auth, c.getActiveSession); diff --git a/backend/src/routes/customSims.js b/backend/src/routes/customSims.js index 13e3a42..5faf1f8 100644 --- a/backend/src/routes/customSims.js +++ b/backend/src/routes/customSims.js @@ -5,7 +5,7 @@ * НЕ blanket requireRole на роутере: список/чтение доступны и ученику (published). */ const express = require('express'); const router = express.Router(); -const { authMiddleware, requireRole } = require('../middleware/auth'); +const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth'); const { requireFeature } = require('../middleware/features'); const c = require('../controllers/customSimController'); @@ -22,9 +22,9 @@ router.get('/:id', c.get); // @public-by-design: router-level authMiddleware (above) + ownership/published check in handler router.get('/:id/related', c.related); -router.post('/', gate, requireRole('teacher', 'admin'), c.create); +router.post('/', gate, requireRole('teacher', 'admin'), requirePermission('simbuilder.use'), c.create); // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler -router.put('/:id', gate, requireRole('teacher', 'admin'), c.update); +router.put('/:id', gate, requireRole('teacher', 'admin'), requirePermission('simbuilder.use'), c.update); // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler router.delete('/:id', gate, requireRole('teacher', 'admin'), c.remove); diff --git a/backend/src/routes/exam-prep.js b/backend/src/routes/exam-prep.js index ff4d478..27fb1b0 100644 --- a/backend/src/routes/exam-prep.js +++ b/backend/src/routes/exam-prep.js @@ -1,10 +1,13 @@ 'use strict'; const router = require('express').Router(); const db = require('../db/db'); -const { authMiddleware, requireRole } = require('../middleware/auth'); +const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth'); const access = require('../services/contentAccess'); router.use(authMiddleware); +// Ролевой доступ к подготовке к экзаменам: ученик без права exam.access закрыт; +// учитель/админ проходят всегда. Видимость конкретных модулей — в «Доступ · контент». +router.use(requirePermissionForStudents('exam.access')); /* Гейт доступа: любой маршрут с :examKey проверяется по allowlist. Админ/учитель проходят всегда; ученик — только при наличии правила. */ diff --git a/backend/src/routes/flashcards.js b/backend/src/routes/flashcards.js index 3db6945..0d76fc7 100644 --- a/backend/src/routes/flashcards.js +++ b/backend/src/routes/flashcards.js @@ -5,7 +5,7 @@ const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const fc = require('../controllers/flashcardController'); -const { authMiddleware, requireRole } = require('../middleware/auth'); +const { authMiddleware, requireRole, requirePermission, requirePermissionForStudents } = require('../middleware/auth'); const { requireOwnership } = require('../middleware/ownership'); /* ── multer для картинок карточек ─────────────────────────────────────── @@ -30,6 +30,9 @@ const fcUpload = multer({ }); router.use(authMiddleware); +// Ролевой доступ к разделу флеш-карт: ученик без права flashcards.access закрыт; +// учитель/админ проходят всегда (создают и раздают колоды). +router.use(requirePermissionForStudents('flashcards.access')); router.post ('/upload', fcUpload.single('file'), fc.uploadImage); @@ -45,8 +48,8 @@ router.post ('/decks/:id/cards/bulk', fc.addCardsBulk); router.put ('/decks/:id/reorder', requireOwnership({ table: 'flashcard_decks', ownerField: 'user_id' }), fc.reorderCards); // Шаринг колоды (назначение классу/ученику) — только владелец/админ (проверка в хендлере). router.get ('/decks/:id/shares', fc.listShares); -router.post ('/decks/:id/share', requireRole('teacher','admin'), fc.addShare); -router.delete('/decks/:id/share', requireRole('teacher','admin'), fc.removeShare); +router.post ('/decks/:id/share', requireRole('teacher','admin'), requirePermission('flashcards.manage'), fc.addShare); +router.delete('/decks/:id/share', requireRole('teacher','admin'), requirePermission('flashcards.manage'), fc.removeShare); router.get ('/decks/:id/study', fc.getStudySession); router.put ('/cards/:id', fc.updateCard); router.delete('/cards/:id', fc.deleteCard); diff --git a/backend/src/routes/games.js b/backend/src/routes/games.js index b22564d..331cf90 100644 --- a/backend/src/routes/games.js +++ b/backend/src/routes/games.js @@ -1,14 +1,16 @@ const router = require('express').Router(); -const { authMiddleware } = require('../middleware/auth'); +const { authMiddleware, requirePermissionForStudents } = require('../middleware/auth'); const { requireFeature } = require('../middleware/features'); const c = require('../controllers/gamesController'); const hangman = requireFeature('hangman'); const crossword = requireFeature('crossword'); +// Ролевой доступ к учебным играм: ученик без права games.play закрыт, учитель/админ — нет. +const playable = requirePermissionForStudents('games.play'); -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); +router.get('/hangman/word', hangman, authMiddleware, playable, c.hangmanWord); +router.post('/hangman/complete', hangman, authMiddleware, playable, c.hangmanComplete); +router.get('/crossword/generate', crossword, authMiddleware, playable, c.crosswordGenerate); +router.post('/crossword/complete', crossword, authMiddleware, playable, c.crosswordComplete); module.exports = router; diff --git a/backend/src/routes/live.js b/backend/src/routes/live.js index 5760dbe..662e2ff 100644 --- a/backend/src/routes/live.js +++ b/backend/src/routes/live.js @@ -1,10 +1,10 @@ const router = require('express').Router(); -const { authMiddleware, requireRole } = require('../middleware/auth'); +const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth'); const c = require('../controllers/liveController'); const teacher = [authMiddleware, requireRole('teacher', 'admin')]; -router.post('/', ...teacher, c.create); +router.post('/', ...teacher, requirePermission('livequiz.host'), c.create); router.get('/:id', ...teacher, c.getSession); router.put('/:id/question', ...teacher, c.setQuestion); router.get('/:id/results', ...teacher, c.results); diff --git a/backend/src/routes/materials.js b/backend/src/routes/materials.js index 2090da4..ca5748a 100644 --- a/backend/src/routes/materials.js +++ b/backend/src/routes/materials.js @@ -1,7 +1,7 @@ 'use strict'; const express = require('express'); const router = express.Router(); -const { authMiddleware, requireRole } = require('../middleware/auth'); +const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth'); const c = require('../controllers/studentMaterialsController'); router.use(authMiddleware); @@ -10,7 +10,8 @@ router.use(authMiddleware); router.post('/:id/share', requireRole('teacher', 'admin'), c.share); router.get('/', c.list); -router.post('/', c.create); +// Сохранение в «Мои материалы»: ученик без права materials.save закрыт, учитель/админ проходят. +router.post('/', requirePermissionForStudents('materials.save'), c.create); // Collections (folders) — literal '/collections' prefix before '/:id' router.post('/collections', c.createCollection); diff --git a/backend/src/routes/submissions.js b/backend/src/routes/submissions.js index 81d16ec..6988a50 100644 --- a/backend/src/routes/submissions.js +++ b/backend/src/routes/submissions.js @@ -1,7 +1,7 @@ const router = require('express').Router(); const multer = require('multer'); const path = require('path'); -const { authMiddleware, requireRole } = require('../middleware/auth'); +const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth'); const ctrl = require('../controllers/submissionsController'); const { fixUtf8Name } = require('../utils/fixUtf8'); @@ -47,7 +47,7 @@ const upload = multer({ /* ── routes ─────────────────────────────────────────────────────────── */ router.use(authMiddleware); -router.post('/', requireRole('student', 'free_student'), upload.single('file'), fixUtf8Name, ctrl.submit); +router.post('/', requireRole('student', 'free_student'), requirePermission('homework.submit'), upload.single('file'), fixUtf8Name, ctrl.submit); router.get('/my', requireRole('student', 'free_student'), ctrl.getMySubmissions); router.get('/log', requireRole('admin'), ctrl.getSubmissionLog); router.delete('/log', requireRole('admin'), ctrl.clearSubmissionLog); @@ -55,6 +55,6 @@ router.get('/', requireRole('teacher', 'admin'), ctrl.getClassSubm router.patch('/:id', requireRole('teacher', 'admin'), ctrl.reviewSubmission); router.get('/:id/download', ctrl.downloadSubmission); router.delete('/:id', ctrl.deleteSubmission); -router.post('/:id/resubmit', requireRole('student', 'free_student'), upload.single('file'), fixUtf8Name, ctrl.resubmit); +router.post('/:id/resubmit', requireRole('student', 'free_student'), requirePermission('homework.submit'), upload.single('file'), fixUtf8Name, ctrl.resubmit); module.exports = router;