feat(permissions): C-4a — API конструктора ролей (/api/roles, admin)

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) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 15:21:44 +03:00
parent 32c2c44b76
commit bdc8bef857
6 changed files with 215 additions and 3 deletions
@@ -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 изменение НЕ инвалидирует
+113
View File
@@ -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 };