From bdc8bef857a2484a1cf054057e32a8be08896a0c Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 3 Jun 2026 15:21:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(permissions):=20C-4a=20=E2=80=94=20API=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=20=D1=80=D0=BE=D0=BB=D0=B5=D0=B9=20(/api/roles,=20?= =?UTF-8?q?admin)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rolesController + routes/roles (admin, inline guards): GET список (с числом пользователей), POST создать кастомную роль (имя-идентификатор + метка + base_roles; засев прав из функциональной базы), PUT изменить, DELETE удалить (пользователей возвращает на базу), GET /:name/permissions (эффективная карта база+оверлей + defs). setPermission теперь принимает кастомные роли (ключ валидируется по базе, хранится под именем роли). Смонтировано в server.js + тест-харнесс. Тест roles-api 5/5. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/controllers/permissionsController.js | 18 ++- backend/src/controllers/rolesController.js | 113 ++++++++++++++++++ backend/src/routes/roles.js | 15 +++ backend/src/server.js | 1 + backend/tests/roles-api.test.js | 70 +++++++++++ backend/tests/setup.js | 1 + 6 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 backend/src/controllers/rolesController.js create mode 100644 backend/src/routes/roles.js create mode 100644 backend/tests/roles-api.test.js diff --git a/backend/src/controllers/permissionsController.js b/backend/src/controllers/permissionsController.js index 40e960a..492cfae 100644 --- a/backend/src/controllers/permissionsController.js +++ b/backend/src/controllers/permissionsController.js @@ -38,9 +38,21 @@ function getPermissions(_req, res) { /* ── POST /api/permissions { role, permission, enabled } ─────────────── */ function setPermission(req, res) { const { role, permission, enabled } = req.body; - if (!['teacher', 'student'].includes(role)) - return res.status(400).json({ error: 'Invalid role' }); - if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === role)) + // Встроенные конфигурируемые роли — напрямую; кастомная роль — ключи валидируем + // по её функциональной базе, но храним под именем роли. + let keyRole; + if (['teacher', 'student'].includes(role)) { + keyRole = role; + } else { + let cr = null; + try { cr = db.prepare('SELECT base_roles, is_builtin FROM roles WHERE name = ?').get(role); } catch (_e) { cr = null; } + if (!cr || cr.is_builtin) return res.status(400).json({ error: 'Invalid role' }); + let bases = []; + try { bases = JSON.parse(cr.base_roles || '[]'); } catch (_e) { bases = []; } + const primary = bases.find(b => ['teacher', 'student', 'free_student'].includes(b)) || 'student'; + keyRole = primary === 'free_student' ? 'student' : primary; + } + if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === keyRole)) return res.status(400).json({ error: 'Unknown permission' }); // Серверное применение прав — ЖИВОЕ: requirePermission() читает role_permissions // из БД на каждый запрос (auth.js). Поэтому role-level изменение НЕ инвалидирует diff --git a/backend/src/controllers/rolesController.js b/backend/src/controllers/rolesController.js new file mode 100644 index 0000000..9cf4164 --- /dev/null +++ b/backend/src/controllers/rolesController.js @@ -0,0 +1,113 @@ +'use strict'; +/* rolesController — конструктор кастомных ролей (Phase C, C-4). + * Кастомная роль = имя + метка + base_roles (какие встроенные гейты проходит; + * base_roles[0] — функциональная база для веток контроллеров и дефолтов прав) + + * свой набор прав в role_permissions (засевается из базы при создании). */ +const db = require('../db/db'); +const { audit } = require('../utils/audit'); +const registry = require('../permissions/registry'); + +const BUILTIN = ['admin', 'teacher', 'student', 'free_student']; +const CFG_BASE = (primary) => (primary === 'free_student' ? 'student' : primary); // ключи прав free_student = student + +function primaryBase(baseRolesJson) { + let arr = []; + try { arr = JSON.parse(baseRolesJson || '[]'); } catch (_e) { arr = []; } + return arr.find(b => BUILTIN.includes(b)) || 'student'; +} + +/* GET /api/roles → [{name,label,baseRoles,isBuiltin,users}] */ +function listRoles(_req, res) { + const rows = db.prepare('SELECT name, label, base_roles, is_builtin FROM roles ORDER BY is_builtin DESC, name').all(); + const counts = {}; + for (const c of db.prepare("SELECT COALESCE(NULLIF(custom_role,''), role) AS r, COUNT(*) n FROM users GROUP BY COALESCE(NULLIF(custom_role,''), role)").all()) counts[c.r] = c.n; + res.json(rows.map(r => ({ + name: r.name, label: r.label, + baseRoles: (() => { try { return JSON.parse(r.base_roles || '[]'); } catch (_e) { return []; } })(), + isBuiltin: r.is_builtin === 1, users: counts[r.name] || 0, + }))); +} + +/* POST /api/roles { name, label, baseRoles[] } — создать кастомную роль */ +function createRole(req, res) { + let { name, label, baseRoles } = req.body || {}; + name = String(name || '').trim().toLowerCase().replace(/[^a-z0-9_]/g, ''); + if (!name) return res.status(400).json({ error: 'Имя роли: латиница/цифры/подчёркивание' }); + if (BUILTIN.includes(name)) return res.status(400).json({ error: 'Имя занято встроенной ролью' }); + if (db.prepare('SELECT 1 FROM roles WHERE name = ?').get(name)) return res.status(409).json({ error: 'Роль уже существует' }); + const bases = Array.isArray(baseRoles) ? baseRoles.filter(b => BUILTIN.includes(b)) : []; + if (!bases.length) return res.status(400).json({ error: 'Нужна хотя бы одна базовая роль' }); + label = String(label || name).trim().slice(0, 100) || name; + const primary = bases[0]; + + db.transaction(() => { + db.prepare('INSERT INTO roles (name, label, base_roles, is_builtin) VALUES (?, ?, ?, 0)').run(name, label, JSON.stringify(bases)); + // Засев прав из функциональной базы (текущие role-level значения базы). + const baseRows = db.prepare('SELECT permission, enabled FROM role_permissions WHERE role = ?').all(CFG_BASE(primary)); + const ins = db.prepare('INSERT OR IGNORE INTO role_permissions (role, permission, enabled) VALUES (?, ?, ?)'); + for (const br of baseRows) ins.run(name, br.permission, br.enabled); + })(); + audit(req, 'role.create', `role:${name}`, `base=${bases.join(',')}`); + res.json({ ok: true, name, label, baseRoles: bases }); +} + +/* PUT /api/roles/:name { label?, baseRoles? } — изменить кастомную роль */ +function updateRole(req, res) { + const name = req.params.name; + const row = db.prepare('SELECT is_builtin FROM roles WHERE name = ?').get(name); + if (!row) return res.status(404).json({ error: 'Роль не найдена' }); + if (row.is_builtin) return res.status(400).json({ error: 'Встроенную роль нельзя изменять' }); + const { label, baseRoles } = req.body || {}; + const fields = [], args = []; + if (label !== undefined) { fields.push('label = ?'); args.push(String(label).trim().slice(0, 100) || name); } + if (Array.isArray(baseRoles)) { + const bases = baseRoles.filter(b => BUILTIN.includes(b)); + if (!bases.length) return res.status(400).json({ error: 'Нужна хотя бы одна базовая роль' }); + fields.push('base_roles = ?'); args.push(JSON.stringify(bases)); + } + if (!fields.length) return res.json({ ok: true }); + args.push(name); + db.prepare(`UPDATE roles SET ${fields.join(', ')} WHERE name = ?`).run(...args); + audit(req, 'role.update', `role:${name}`, null); + res.json({ ok: true }); +} + +/* DELETE /api/roles/:name — удалить кастомную роль (пользователей вернуть на базу) */ +function deleteRole(req, res) { + const name = req.params.name; + const row = db.prepare('SELECT base_roles, is_builtin FROM roles WHERE name = ?').get(name); + if (!row) return res.status(404).json({ error: 'Роль не найдена' }); + if (row.is_builtin) return res.status(400).json({ error: 'Встроенную роль нельзя удалить' }); + const primary = primaryBase(row.base_roles); + let reassigned = 0; + db.transaction(() => { + const users = db.prepare('SELECT id FROM users WHERE custom_role = ?').all(name); + const upd = db.prepare('UPDATE users SET role = ?, custom_role = NULL, token_version = token_version + 1 WHERE id = ?'); + for (const u of users) { upd.run(primary, u.id); reassigned++; } + db.prepare('DELETE FROM role_permissions WHERE role = ?').run(name); + db.prepare('DELETE FROM roles WHERE name = ?').run(name); + })(); + audit(req, 'role.delete', `role:${name}`, `reassigned ${reassigned} → ${primary}`); + res.json({ ok: true, reassigned, base: primary }); +} + +/* GET /api/roles/:name/permissions → { name, base, permissions:{key:bool}, definitions:[...] } + Эффективные права роли (база + наложение кастомной) + определения ключей базы. */ +function getRolePermissions(req, res) { + const name = req.params.name; + const row = db.prepare('SELECT base_roles, is_builtin FROM roles WHERE name = ?').get(name); + if (!row) return res.status(404).json({ error: 'Роль не найдена' }); + const primary = row.is_builtin ? name : primaryBase(row.base_roles); + const cfgBase = CFG_BASE(primary); + const definitions = registry.byRole(cfgBase); // [] для admin + const map = {}; + for (const r of db.prepare('SELECT permission, enabled FROM role_permissions WHERE role = ?').all(cfgBase)) map[r.permission] = r.enabled === 1; + if (!row.is_builtin) { + for (const r of db.prepare('SELECT permission, enabled FROM role_permissions WHERE role = ?').all(name)) map[r.permission] = r.enabled === 1; + } + // дефолты для ключей, которых нет в БД + for (const d of definitions) if (map[d.key] === undefined) map[d.key] = !!d.default; + res.json({ name, base: cfgBase, permissions: map, definitions }); +} + +module.exports = { listRoles, createRole, updateRole, deleteRole, getRolePermissions, primaryBase, CFG_BASE }; diff --git a/backend/src/routes/roles.js b/backend/src/routes/roles.js new file mode 100644 index 0000000..e446a0a --- /dev/null +++ b/backend/src/routes/roles.js @@ -0,0 +1,15 @@ +'use strict'; +/* /api/roles — конструктор кастомных ролей (admin). См. rolesController. */ +const router = require('express').Router(); +const { authMiddleware, requireRole } = require('../middleware/auth'); +const c = require('../controllers/rolesController'); + +router.use(authMiddleware); + +router.get('/', requireRole('admin'), c.listRoles); +router.post('/', requireRole('admin'), c.createRole); +router.get('/:name/permissions', requireRole('admin'), c.getRolePermissions); +router.put('/:name', requireRole('admin'), c.updateRole); +router.delete('/:name', requireRole('admin'), c.deleteRole); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index 71e676e..5395ff7 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -163,6 +163,7 @@ app.use('/api/files', fileRoutes); app.use('/api/tests', testRoutes); app.use('/api/notifications', notificationRoutes); app.use('/api/permissions', permissionRoutes); +app.use('/api/roles', require('./routes/roles')); app.use('/api/submissions', submissionRoutes); app.use('/api/courses', courseRoutes); app.use('/api/lessons', lessonRoutes); diff --git a/backend/tests/roles-api.test.js b/backend/tests/roles-api.test.js new file mode 100644 index 0000000..e4acbcc --- /dev/null +++ b/backend/tests/roles-api.test.js @@ -0,0 +1,70 @@ +'use strict'; +/** + * Phase C, C-4a — API конструктора ролей (/api/roles, admin). + * Имя роли — латинский идентификатор (sanitize), метка — любая. Создание засевает + * права из функциональной базы; setPermission принимает кастомные роли (ключ по базе); + * удаление возвращает пользователей на базу; встроенные роли защищены; не-админу 403. + */ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { db, getToken, inject, cleanup } = require('./setup'); + +after(() => cleanup()); + +describe('roles API (C-4)', () => { + let adminToken, studentUid; + + before(async () => { + adminToken = (await getToken('admin')).token; + studentUid = (await getToken('student')).userId; + }); + + it('создание кастомной роли (имя санитизируется) + засев прав из базы', async () => { + const r = await inject('POST', '/api/roles', { name: 'Curator-1', label: 'Куратор', baseRoles: ['teacher'] }, adminToken); + assert.equal(r.status, 200, JSON.stringify(r.body)); + assert.equal(r.body.name, 'curator1', 'имя приведено к латинскому идентификатору'); + + const list = await inject('GET', '/api/roles', null, adminToken); + assert.ok(list.body.some(x => x.name === 'curator1' && !x.isBuiltin && x.baseRoles[0] === 'teacher')); + + const rp = await inject('GET', '/api/roles/curator1/permissions', null, adminToken); + assert.equal(rp.status, 200); + assert.equal(rp.body.base, 'teacher'); + assert.ok(rp.body.definitions.length > 0, 'есть определения ключей базы'); + assert.equal(rp.body.permissions['classes.manage'], true, 'classes.manage засеян из teacher (default 1)'); + }); + + it('конфиг права кастомной роли через setPermission (ключ валиден по базе)', async () => { + const r = await inject('POST', '/api/permissions', { role: 'curator1', permission: 'questions.manage', enabled: true }, adminToken); + assert.equal(r.status, 200, JSON.stringify(r.body)); + const row = db.prepare("SELECT enabled FROM role_permissions WHERE role='curator1' AND permission='questions.manage'").get(); + assert.ok(row && row.enabled === 1, 'право сохранено под именем кастомной роли'); + + const bad = await inject('POST', '/api/permissions', { role: 'curator1', permission: 'tests.free', enabled: true }, adminToken); + assert.equal(bad.status, 400, 'tests.free — студенческий ключ, не для teacher-базы'); + }); + + it('удаление роли возвращает пользователей на функциональную базу', async () => { + await inject('PATCH', `/api/admin/users/${studentUid}/role`, { role: 'curator1' }, adminToken); + let row = db.prepare('SELECT role, custom_role FROM users WHERE id=?').get(studentUid); + assert.equal(row.custom_role, 'curator1'); assert.equal(row.role, 'teacher'); + + const del = await inject('DELETE', '/api/roles/curator1', null, adminToken); + assert.equal(del.status, 200); + assert.ok(del.body.reassigned >= 1); + row = db.prepare('SELECT role, custom_role FROM users WHERE id=?').get(studentUid); + assert.equal(row.custom_role, null, 'custom_role снят'); + assert.equal(row.role, 'teacher', 'остался на функциональной базе'); + assert.ok(!db.prepare("SELECT 1 FROM roles WHERE name='curator1'").get(), 'роль удалена'); + }); + + it('встроенную роль нельзя удалить/изменить', async () => { + assert.equal((await inject('DELETE', '/api/roles/teacher', null, adminToken)).status, 400); + assert.equal((await inject('PUT', '/api/roles/teacher', { label: 'x' }, adminToken)).status, 400); + }); + + it('не-админу — 403', async () => { + const fresh = await getToken('student'); // свежий токен (studentTok мог быть инвалидирован сменой роли) + assert.equal((await inject('GET', '/api/roles', null, fresh.token)).status, 403); + }); +}); diff --git a/backend/tests/setup.js b/backend/tests/setup.js index 894cebf..26e993d 100644 --- a/backend/tests/setup.js +++ b/backend/tests/setup.js @@ -47,6 +47,7 @@ app.use('/api/permissions', require('../src/routes/permissions')); app.use('/api/access', require('../src/routes/access')); app.use('/api/lab', require('../src/routes/lab')); app.use('/api/courses', require('../src/routes/courses')); +app.use('/api/roles', require('../src/routes/roles')); // Feature-gated routes (requireFeature checks app_settings in DB) const { requireFeature } = require('../src/middleware/features');