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:
@@ -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 изменение НЕ инвалидирует
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user