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 };
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user