docs(access): план переработки системы прав (ревью + фазы)

Единая модель видимости контента: расширение content_access на course/sim
(доступ по классам), разведение «способности (роли)» vs «видимость (классы/
ученики)», целостность (purgeAccessFor + чистка при kick), UX админки (матрица
класс×контент, поиск/группировка, эффективный доступ, групповые правила по
предмету/параллели), серверный гейт HTML через cookie-сессию. 4 фазы + риски.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 12:32:00 +03:00
parent 5a2a1be089
commit edb98895df
+151
View File
@@ -0,0 +1,151 @@
# PLAN — Переработка системы выдачи прав (Access / Permissions Redesign)
> Составлен 2026-06-03 (Opus) по итогам ревью. Цель — свести «кто что видит» к единой
> связной модели, закрыть дыры целостности и безопасности, сделать админку удобной при росте
> числа учебников/классов/учеников. Источники-факты (проверено по коду):
> - `backend/src/services/contentAccess.js` — резолвер allowlist (учебники/экзамены).
> - `backend/src/routes/access.js` — API `/api/access` (catalog/targets/summary/rules/class).
> - `backend/src/db/migrations/040_content_access.sql` — таблица `content_access`.
> - `backend/src/permissions/registry.js` (23 ключа) + `role_permissions`/`user_permissions`,
> middleware `requirePermission` (`backend/src/middleware/auth.js`).
> - Админ-UI: `frontend/js/admin/sections/access.js` (контент по классам) и `permissions.js` (роли).
> - Гейты: `textbooks.js` / `exam-prep.js` (`router.param`), отдача HTML `server.js:~436/~460`,
> клиентский редирект `frontend/js/textbook-tracker.js`, `frontend/js/exam-prep/common.js`.
---
## 1. Текущее состояние — 2,5 параллельные системы
| Система | Регулирует | Гранулярность | Хранилище | UI |
|---|---|---|---|---|
| **A. content_access** | учебники, экзамены | класс + ученик (allowlist, ученик > класс) | `content_access` (040) | вкладка «Доступ к учебникам» |
| **B. role/user permissions** | способности (вести вопросы/классы/курсы), **симуляции**, испытания, магазин | глобально по роли + override на пользователя | `role_permissions`+`user_permissions` (registry.js) | вкладка «Права доступа» |
| **C. курсы/теория** | видимость курсов | `is_published` + привязка к классу | таблицы курсов | — (нет единого места) |
Проблема: на один вопрос «кому показывать контент» отвечают три механизма и две похоже названные вкладки.
---
## 2. Принципы целевой модели
1. **Два чётко разведённых понятия:**
- **Способности (capabilities)** — что роль/пользователь *умеет делать* (вести классы, модерация,
покупки в магазине, управление курсами). Остаётся в `role_permissions`/`user_permissions`.
- **Видимость контента (content visibility)** — что классу/ученику *показывать* (учебники,
экзамены, **курсы/теория**, **симуляции**). Переезжает в единый `content_access`.
2. **Allowlist по умолчанию** сохраняем (безопасно). Правило **ученик > класс** сохраняем.
3. **Один резолвер** `canAccess(user, type, ref)` для всех типов контента.
4. **Целостность БД** — ни одного пути, оставляющего «осиротевшие» правила.
5. **Сервер — источник правды**: блокировка не должна держаться только на клиенте.
Что НЕ трогаем: ролевые «способности» (manage-права учителя, магазин) остаются по ролям —
их некорректно вешать на классы.
---
## 3. Находки ревью (приоритеты)
- **P0 фрагментация:** теорию/симуляции нельзя открыть *одному классу* (только глобально по роли);
путаница «Права доступа» vs «Доступ к учебникам».
- **P1 HTML не гейтится на сервере:** `/textbook/:slug`, `/exam-prep/:examKey` отдают HTML всем;
блок — только клиентский редирект `/403` (причина: JWT в `localStorage`, не cookie).
- **P1 масштаб UI:** плоские списки, нет поиска/группировки; «отдельные ученики» `LIMIT 500`.
- **P2 осиротевшие per-student правила:** `kickMember` (classController.js:405) не чистит правила ученика.
- **P2 нет FK** на `content_access` → чистота на ручных `DELETE`.
- **P2 нет обзора** «класс × контент» и операций «открыть весь предмет/параллель».
---
## 4. Целевая модель данных
`content_access` расширяется (обратносовместимо):
```sql
-- было: content_type IN ('textbook','exam')
-- станет:
content_type IN ('textbook','exam','course','sim')
content_ref -- textbook: slug хаба; exam: exam_key; course: course slug/id; sim: sim id/slug
scope IN ('class','student')
-- + опционально групповые правила (Фаза 2):
-- scope IN ('class','student','tag'), где target_ref = 'math:5' (subject:grade)
allow IN (0,1)
```
Резолвер `contentAccess.js` обобщается:
- `canAccess(user, type, ref)` уже generic — добавить `canAccessCourse`, `canAccessSim`
и расширить `allowedRefs`/`filterX` на новые типы.
- Порядок разрешения: **личное правило ученика → правило класса (любой `allow=1`) →
(Фаза 2) групповое правило по тегу → default deny**.
- Для `sim`/`course` на время миграции — мост: если правил нет, читать старое ролевое
`simulations.access`/публикацию, чтобы переход не отнял доступ (см. Фаза 1, миграция данных).
---
## 5. Фазы
### Фаза 0 — Целостность и быстрые победы (низкий риск, без смены модели)
- [ ] `purgeAccessFor({scope, id})` в `services/contentAccess.js` — единая чистка правил;
вызвать из `deleteClass` (уже чистит — заменить на общий вызов), `_deleteUserTx`,
и **добавить в `kickMember`** (решить: чистить ли личные правила при исключении — по умолчанию
чистить только если у ученика нет других классов с этим контентом; минимально — убрать
«висячие» `student`-deny). Зафиксировать поведение тестом.
- [ ] Подтверждение для массового «Закрыть у всех / Закрыть весь» в `access.js` (сейчас мгновенно).
- [ ] Тех-долг БД: добавить чистящие триггеры ИЛИ оставить ручную чистку, но покрыть тестом
«после удаления класса/ученика — нет строк content_access с этим target».
- [ ] Тесты: резолвер (ученик>класс, default deny), чистка при удалениях.
### Фаза 1 — Единая модель видимости (расширение content_access на course/sim)
- [ ] Миграция `05X_content_access_types.sql`: расширить CHECK `content_type` до
`('textbook','exam','course','sim')`. **Перенос данных**: для каждого включённого
курса/симуляции, видимых сейчас (по роли/публикации), создать `allow=1` всем существующим
классам (как делала 040 для учебников) — чтобы переход не отнял доступ.
- [ ] `contentAccess.js`: `canAccessCourse`, `canAccessSim`, расширить `allowedRefs`/фильтры.
- [ ] Гейты: подключить резолвер на роутах курсов (`GET /api/courses` — сейчас гейтится только
`is_published`+класс) и симуляций (`/api/gamification/lab-activity` и список симуляций).
Ролевой `simulations.access` оставить как «способность видеть лабу вообще» ИЛИ перенести
полностью в content_access (решить в начале фазы — по умолчанию: видимость по классам,
`simulations.access` депрекейтим до «модуль доступен роли»).
- [ ] `/api/access/catalog` + `targets` + `summary` + `rules`: добавить секции «Курсы», «Симуляции».
- [ ] Тесты резолвера для новых типов + миграционный тест (после переноса все классы видят
то же, что и раньше).
### Фаза 2 — UX админки (удобство при масштабе)
- [ ] **Третий режим «Матрица»**: таблица класс × контент с чекбоксами, фильтр по предмету/типу,
поиск. Обзор и правка «кто что видит» одним экраном.
- [ ] **Поиск + группировка по предмету** в левой колонке (вместо плоских списков).
- [ ] **Групповые правила (теги `subject:grade`)**: кнопка «Открыть весь предмет / всю параллель»
одной записью; резолвер учитывает теги. (Требует `scope='tag'` + UI.)
- [ ] **«Эффективный доступ»**: для выбранного ученика/класса показать, что реально видит и
*почему* (унаследовано от класса / личное правило / групповое) — снимает путаницу tri-state.
- [ ] **Пресеты**: «стартовый набор для N класса» в один клик.
- [ ] **История правила** из `audit` (кто/когда открыл) в карточке.
- [ ] **Объединить вкладки** под раздел «Доступ» → под-вкладки «Контент» (видимость) и
«Способности» (роли). Прекращает путаницу двух похожих названий.
- [ ] Пагинация/виртуализация для «отдельных учеников» (убрать `LIMIT 500` как потолок).
### Фаза 3 — Серверный гейт HTML (безопасность, крупная)
- [ ] Перейти на аутентификацию через **httpOnly-cookie сессию** (или дублировать JWT в cookie),
чтобы `/textbook/:slug` и `/exam-prep/:examKey` могли проверять доступ ПЕРЕД `res.sendFile`.
- [ ] Middleware-гейт на отдачу HTML: нет доступа → редирект/`/403` на сервере.
- [ ] Сохранить клиентский редирект как запасной слой.
- [ ] Прогон полного backend-набора + ручная проверка логина/cookie во всех ролях.
- ⚠️ Затрагивает аутентификацию целиком — делать отдельной веткой, с откатом.
---
## 6. Тестирование
- Юнит резолвера `contentAccess` (все типы, ученик>класс, теги, default deny).
- Миграционные тесты: после 040-аналога для course/sim — прежняя видимость сохранена.
- Тесты целостности: удаление класса/ученика/исключение из класса → нет осиротевших правил.
- E2E-смоук админки по ролям (admin видит всё; teacher — только свои классы/учеников).
- pre-commit гоняет полный backend-набор (baseline 3 Auth-фейла — не трогать).
## 7. Риски и откат
- Фаза 1 меняет поведение симуляций/курсов → обязательный перенос данных «открыть всем классам»
+ флаг отката (если что — вернуть ролевую проверку). Согласовать с пользователем ДО миграции
(в памяти объединение было «отложено» — теперь решено делать: [[project_content_access]]).
- Фаза 3 трогает аутентификацию — отдельная ветка, не смешивать с контентными фазами.
## 8. Порядок исполнения (рекомендация)
Фаза 0 → Фаза 2 (часть: матрица/поиск/эффективный доступ — дают пользу сразу на текущей модели) →
Фаза 1 (объединение типов) → Фаза 2 (групповые правила/теги, требуют новой модели) → Фаза 3.