From 5aa2dd1a4baa2d9a3bc96e144b6d6d66343f2242 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 3 Jun 2026 14:57:10 +0300 Subject: [PATCH] =?UTF-8?q?feat(permissions):=20C-1=20=E2=80=94=20=D1=84?= =?UTF-8?q?=D1=83=D0=BD=D0=B4=D0=B0=D0=BC=D0=B5=D0=BD=D1=82=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=BC=D0=BD=D1=8B=D1=85=20=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D0=B5=D0=B9=20(roles=20table=20+=20=D0=BD=D0=B0=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=D0=B4=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B3=D0=B5?= =?UTF-8?q?=D0=B9=D1=82=D0=BE=D0=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase C, Stage C-1 (ветка feature/custom-roles): таблица roles (name, label, base_roles JSON, is_builtin) + засев встроенных. auth.effectiveRoles(role) — кастомная роль наследует base_roles (какие встроенные гейты проходит); встроенные — быстрый путь без БД. requireRole() теперь проверяет пересечение allowed с effectiveRoles → 111 существующих гейтов не задеты (встроенные ведут себя как прежде). Дизайн: PHASE_C_DESIGN.md. Тест effectiveRoles 5/5; полный backend pass. ВАЖНО (обнаружено): users.role в канон-схеме имеет CHECK (admin/teacher/student/ free_student), безопасно пересобрать users (FK от многих таблиц, миграции в txn) нельзя → присвоение кастомной роли пользователю пойдёт через users.custom_role (C-2). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/db/migrations/054_roles.sql | 22 +++++++++ backend/src/middleware/auth.js | 29 +++++++++--- backend/tests/custom-roles.test.js | 54 ++++++++++++++++++++++ plans/permissions-rework/PHASE_C_DESIGN.md | 52 +++++++++++++++++++++ 4 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 backend/src/db/migrations/054_roles.sql create mode 100644 backend/tests/custom-roles.test.js create mode 100644 plans/permissions-rework/PHASE_C_DESIGN.md diff --git a/backend/src/db/migrations/054_roles.sql b/backend/src/db/migrations/054_roles.sql new file mode 100644 index 0000000..2dd1ba8 --- /dev/null +++ b/backend/src/db/migrations/054_roles.sql @@ -0,0 +1,22 @@ +-- 054_roles.sql +-- Phase C (кастомные роли), Stage C-1 — реестр ролей. +-- Модель: роль наследует «базовые роли» (base_roles) — какие встроенные гейты +-- requireRole она проходит. Встроенные роли наследуют сами себя. Кастомную роль +-- админ создаёт со своим набором base_roles (доступ) и набором прав (см. C-2). +-- +-- users.role хранит ИМЯ роли (CHECK на users.role нет — новое имя допустимо). +-- requireRole() расширяет роль пользователя до effectiveRoles по этой таблице. + +CREATE TABLE IF NOT EXISTS roles ( + name TEXT PRIMARY KEY, + label TEXT NOT NULL, + base_roles TEXT NOT NULL DEFAULT '[]', -- JSON-массив встроенных ролей, чьи гейты проходит + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +INSERT OR IGNORE INTO roles (name, label, base_roles, is_builtin) VALUES + ('admin', 'Администратор', '["admin"]', 1), + ('teacher', 'Учитель', '["teacher"]', 1), + ('student', 'Ученик', '["student"]', 1), + ('free_student', 'Свободный ученик', '["free_student","student"]', 1); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 7a2acc2..9037518 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -32,13 +32,30 @@ function authMiddleware(req, res, next) { } } +/* Кастомные роли наследуют «базовые роли» (какие встроенные гейты проходят). + Встроенные роли — быстрый путь без обращения к БД. */ +const BUILTIN_ROLES = new Set(['admin', 'teacher', 'student', 'free_student']); +let _roleBaseStmt = null; +function effectiveRoles(role) { + if (!role) return []; + if (BUILTIN_ROLES.has(role)) return [role]; + try { + if (!_roleBaseStmt) _roleBaseStmt = db.prepare('SELECT base_roles FROM roles WHERE name = ?'); + const row = _roleBaseStmt.get(role); + if (row && row.base_roles) { + const arr = JSON.parse(row.base_roles); + if (Array.isArray(arr) && arr.length) return arr.indexOf(role) >= 0 ? arr : arr.concat(role); + } + } catch (_e) { /* таблицы roles может не быть на старом инстансе — деградация к самой роли */ } + return [role]; +} + function requireRole(...roles) { return (req, res, next) => { - if (!roles.includes(req.user?.role)) { - if (req.user) logDenied(req, 'forbidden', `роль ${req.user.role}; требуется ${roles.join('/')}`); - return res.status(403).json({ error: 'Forbidden' }); - } - next(); + const eff = effectiveRoles(req.user?.role); + if (eff.some(r => roles.includes(r))) return next(); + if (req.user) logDenied(req, 'forbidden', `роль ${req.user.role}; требуется ${roles.join('/')}`); + return res.status(403).json({ error: 'Forbidden' }); }; } @@ -130,4 +147,4 @@ function optionalAuth(req, res, next) { next(); } -module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, perm, parentAuth }; +module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, perm, parentAuth, effectiveRoles }; diff --git a/backend/tests/custom-roles.test.js b/backend/tests/custom-roles.test.js new file mode 100644 index 0000000..92e6280 --- /dev/null +++ b/backend/tests/custom-roles.test.js @@ -0,0 +1,54 @@ +'use strict'; +/** + * Phase C, Stage C-1 (фундамент): таблица roles + effectiveRoles() — кастомная роль + * наследует «базовые роли» (какие встроенные гейты requireRole проходит). + * Здесь тестируем ядро (effectiveRoles) и поведение requireRole-мидлвари на нём. + * Присвоение кастомной роли пользователю (users.custom_role) — Stage C-2 + * (в канонической схеме у users.role есть CHECK, прямое присвоение невозможно). + */ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { db, cleanup } = require('./setup'); +const auth = require('../src/middleware/auth'); + +after(() => cleanup()); + +describe('custom roles — effectiveRoles (C-1)', () => { + before(() => { + const ins = db.prepare("INSERT OR IGNORE INTO roles (name,label,base_roles,is_builtin) VALUES (?,?,?,0)"); + ins.run('methodist', 'Методист', JSON.stringify(['teacher'])); + ins.run('guestx', 'Гость', JSON.stringify([])); + }); + + it('встроенная роль раскрывается в саму себя (без БД)', () => { + assert.deepEqual(auth.effectiveRoles('teacher'), ['teacher']); + assert.deepEqual(auth.effectiveRoles('admin'), ['admin']); + }); + + it('кастомная роль наследует base_roles + себя', () => { + assert.deepEqual(auth.effectiveRoles('methodist').sort(), ['methodist', 'teacher']); + }); + + it('кастомная роль без base = только она сама', () => { + assert.deepEqual(auth.effectiveRoles('guestx'), ['guestx']); + }); + + it('неизвестная роль = только она сама', () => { + assert.deepEqual(auth.effectiveRoles('nope'), ['nope']); + }); + + // requireRole-мидлварь на основе effectiveRoles: allow-путь + function passes(role, allowed) { + let ok = false; + const res = { status: () => ({ json: () => {} }) }; + auth.requireRole(...allowed)({ user: { role, id: 1 }, ip: '127.0.0.1', method: 'GET', originalUrl: '/t', headers: {} }, res, () => { ok = true; }); + return ok; + } + + it('requireRole пропускает кастомную роль по наследованию', () => { + assert.equal(passes('methodist', ['teacher', 'admin']), true); + assert.equal(passes('guestx', ['teacher', 'admin']), false); + assert.equal(passes('teacher', ['teacher', 'admin']), true); + assert.equal(passes('student', ['teacher', 'admin']), false); + }); +}); diff --git a/plans/permissions-rework/PHASE_C_DESIGN.md b/plans/permissions-rework/PHASE_C_DESIGN.md new file mode 100644 index 0000000..1b06071 --- /dev/null +++ b/plans/permissions-rework/PHASE_C_DESIGN.md @@ -0,0 +1,52 @@ +# Phase C — кастомные роли / делегирование / пер-классовый скоуп (дизайн) + +> Составлен 2026-06-03 (Opus). Это **архитектурная** фаза — отдельная ветка + согласование модели +> ДО реализации. Здесь — факты по коду и развилка, чтобы выбрать подход. + +## Факты (проверено по коду/БД) +- `users.role` — `TEXT NOT NULL DEFAULT 'student'`, **без CHECK** → новое значение роли можно хранить + без пересборки таблицы. В ходу: admin, student, teacher (free_student зеркалит student в коде). +- `role_permissions.role` — **CHECK `IN ('teacher','student','free_student')`** → конфиг дефолтов для + новой роли требует миграции-пересборки (SQLite не ALTER-ит CHECK). +- **`requireRole('...')` — 111 вызовов в 24 файлах**, строки ролей захардкожены. Любая новая роль не + пройдёт ни один такой гейт, пока её явно не добавить в нужные списки. +- `registry.byRole()` строит UI только для teacher/student; `requirePermission` читает права live из БД + (user → role → дефолт реестра) — это уже гибко и НЕ зависит от requireRole. + +## Суть проблемы +«Способности» выражены двумя разными механизмами: грубый **requireRole** (кто вообще в разделе) + +тонкий **requirePermission** (конкретное действие). Кастомные роли упираются в requireRole: он не +дата-управляемый. + +## Варианты (по возрастанию объёма/риска) + +### Вариант 1 — Узкий слой: роль «Родитель» (read-only) ★ минимум риска +Уже есть инфраструктура `parentAuth` + `parent_links` (отдельный токен, привязка к ученику). Доделать +родительский доступ как «роль» для просмотра прогресса/оценок ребёнка — без вторжения в requireRole +(родитель ходит через `parentAuth`, не через `requireRole`). Объём: малый. Польза: конкретная и частая. + +### Вариант 2 — Курируемые доп-роли (например «методист/завуч», «классрук», «ассистент») ★ среднее +- Миграция: расширить CHECK `role_permissions.role` (пересборка) на новые роли + засеять их дефолты. +- `registry`: добавить роли в `roles[]` нужных ключей + `byRole` для UI. +- **requireRole точечно**: добавить новую роль в те из 111 вызовов, где она должна иметь доступ + (например «методист» = доступ к аналитике/вопросам, но не к админ-настройкам). +- UI вкладки «Доступ · роли»: секции для новых ролей. +Объём: средний (зависит от числа гейтов на роль). Риск: средний (точечные правки auth). + +### Вариант 3 — Полностью произвольные роли (admin создаёт роли в UI) ★ крупное, рискованное +Нужен **слой capabilities**: заменить `requireRole` на проверку способностей роли (данные в БД), т.е. +переписать ~111 точек на `requireCapability(...)` + UI конструктора ролей + резолвер. Это недели работы +и затрагивает аутентификацию целиком. Делать только при реальной потребности в произвольных ролях. + +## C10 / C11 (отдельные подпункты) +- **C10 делегирование учителю** — дать учителю менять часть студенческих прав *в рамках его классов*. + Технически = пер-классовый скоуп (C11) + расширение прав `/class/:id/bulk` на учителя-владельца. +- **C11 пер-классовый скоуп прав** — правило права на уровне класса (как content_access). Резолвер + начинает учитывать ещё один слой (class-override между user и role). Средне-крупно. + +## Рекомендация +Начать с **Варианта 1 (роль «Родитель», read-only)** — конкретная польза, минимум риска, не трогает +requireRole. Затем, если нужно, **Вариант 2** для одной конкретной роли (скажем «методист»). Вариант 3 +и C11 — только под явный запрос (большой рефактор аутентификации). + +> Реализация — на ветке `feature/custom-roles`. Сначала выбрать вариант/конкретную роль.