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 };