feat(permissions): C-1 — фундамент кастомных ролей (roles table + наследование гейтов)

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) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 14:57:10 +03:00
parent a6ff965d80
commit 5aa2dd1a4b
4 changed files with 151 additions and 6 deletions
+22
View File
@@ -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);
+23 -6
View File
@@ -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 };
+54
View File
@@ -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);
});
});
@@ -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`. Сначала выбрать вариант/конкретную роль.