bdc8bef857
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>
291 lines
16 KiB
JavaScript
291 lines
16 KiB
JavaScript
const db = require('../db/db');
|
|
const { audit } = require('../utils/audit');
|
|
const registry = require('../permissions/registry');
|
|
|
|
/* ── All known permissions — sourced from central registry ────────────── */
|
|
// Only teacher and student entries are exposed to the admin UI.
|
|
// free_student shares the same keys as student (handled in auth.js fallback).
|
|
const ALL_PERMISSIONS = [
|
|
...registry.byRole('teacher'),
|
|
...registry.byRole('student'),
|
|
];
|
|
|
|
/* ── Seed defaults once per startup ───────────────────────────────────── */
|
|
function seedDefaults() {
|
|
const upsert = db.prepare(
|
|
'INSERT OR IGNORE INTO role_permissions (role, permission, enabled) VALUES (?, ?, ?)'
|
|
);
|
|
const run = db.transaction(() => {
|
|
for (const p of ALL_PERMISSIONS) upsert.run(p.role, p.key, p.default);
|
|
});
|
|
run();
|
|
// Чистим просроченные временные оверрайды (B8). Резолвер их и так игнорирует —
|
|
// здесь убираем «мусор» из таблицы.
|
|
try { db.prepare("DELETE FROM user_permissions WHERE expires_at IS NOT NULL AND expires_at <= datetime('now')").run(); } catch (_e) { /* колонки может не быть на старом инстансе */ }
|
|
}
|
|
|
|
/* ── GET /api/permissions ─────────────────────────────────────────────── */
|
|
function getPermissions(_req, res) {
|
|
seedDefaults();
|
|
const rows = db.prepare('SELECT role, permission, enabled FROM role_permissions').all();
|
|
const map = { teacher: {}, student: {} };
|
|
for (const r of rows) {
|
|
if (map[r.role]) map[r.role][r.permission] = r.enabled === 1;
|
|
}
|
|
res.json({ permissions: map, definitions: ALL_PERMISSIONS });
|
|
}
|
|
|
|
/* ── POST /api/permissions { role, permission, enabled } ─────────────── */
|
|
function setPermission(req, res) {
|
|
const { role, permission, enabled } = req.body;
|
|
// Встроенные конфигурируемые роли — напрямую; кастомная роль — ключи валидируем
|
|
// по её функциональной базе, но храним под именем роли.
|
|
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 изменение НЕ инвалидирует
|
|
// сессии — раньше bump token_version разлогинивал ВСЕХ пользователей роли из-за
|
|
// одного тумблера. Клиент подхватит новые права при следующем /permissions/me.
|
|
db.prepare(
|
|
'INSERT OR REPLACE INTO role_permissions (role, permission, enabled) VALUES (?, ?, ?)'
|
|
).run(role, permission, enabled ? 1 : 0);
|
|
audit(req, 'permission.set', `role:${role}/${permission}`, `enabled=${enabled ? 1 : 0}`);
|
|
res.json({ ok: true });
|
|
}
|
|
|
|
/* ── GET /api/permissions/me (any authenticated user) ───────────────── */
|
|
function getMyPermissions(req, res) {
|
|
const uid = req.user.id;
|
|
const role = req.user.role;
|
|
if (role === 'admin') return res.json({ role, permissions: [] }); // admins bypass all
|
|
|
|
seedDefaults();
|
|
// База роли + наложение кастомной роли (если назначена): role_permissions[base]
|
|
// перекрываются role_permissions[customRole].
|
|
const customRole = req.user.customRole || null;
|
|
const roleMap = {};
|
|
for (const r of db.prepare('SELECT permission, enabled FROM role_permissions WHERE role = ?').all(role)) roleMap[r.permission] = r.enabled === 1;
|
|
if (customRole && customRole !== role) {
|
|
for (const r of db.prepare('SELECT permission, enabled FROM role_permissions WHERE role = ?').all(customRole)) roleMap[r.permission] = r.enabled === 1;
|
|
}
|
|
|
|
const userRows = db.prepare(
|
|
"SELECT permission, enabled FROM user_permissions WHERE user_id = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
|
).all(uid);
|
|
const userMap = {};
|
|
for (const r of userRows) userMap[r.permission] = r.enabled === 1;
|
|
|
|
const defs = ALL_PERMISSIONS.filter(p => p.role === role);
|
|
const base = {};
|
|
defs.forEach(d => { base[d.key] = userMap[d.key] !== undefined ? userMap[d.key] : (roleMap[d.key] ?? !!d.default); });
|
|
const result = defs.map(d => ({
|
|
key: d.key,
|
|
effective: base[d.key] && (d.requires || []).every(r => !!base[r]),
|
|
}));
|
|
res.json({ role, permissions: result });
|
|
}
|
|
|
|
/* ── GET /api/permissions/users/:id ──────────────────────────────────── */
|
|
function getUserPermissions(req, res) {
|
|
const uid = Number(req.params.id);
|
|
const target = db.prepare('SELECT id, role, custom_role FROM users WHERE id = ?').get(uid);
|
|
if (!target) return res.status(404).json({ error: 'User not found' });
|
|
|
|
seedDefaults();
|
|
// role-level values: база роли + наложение кастомной роли (если назначена)
|
|
const roleMap = {};
|
|
for (const r of db.prepare('SELECT permission, enabled FROM role_permissions WHERE role = ?').all(target.role)) roleMap[r.permission] = r.enabled === 1;
|
|
if (target.custom_role && target.custom_role !== target.role) {
|
|
for (const r of db.prepare('SELECT permission, enabled FROM role_permissions WHERE role = ?').all(target.custom_role)) roleMap[r.permission] = r.enabled === 1;
|
|
}
|
|
|
|
// user-level overrides (просроченные временные не учитываем)
|
|
const userRows = db.prepare(
|
|
"SELECT permission, enabled, expires_at FROM user_permissions WHERE user_id = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
|
).all(uid);
|
|
const userMap = {}, userExp = {};
|
|
for (const r of userRows) { userMap[r.permission] = r.enabled === 1; if (r.expires_at) userExp[r.permission] = r.expires_at; }
|
|
|
|
const defs = ALL_PERMISSIONS.filter(p => p.role === target.role);
|
|
const base = {};
|
|
defs.forEach(d => { base[d.key] = userMap[d.key] !== undefined ? userMap[d.key] : (roleMap[d.key] ?? !!d.default); });
|
|
const result = defs.map(d => ({
|
|
key: d.key,
|
|
label: d.label,
|
|
desc: d.desc,
|
|
requires: d.requires || [],
|
|
roleVal: roleMap[d.key] ?? d.default, // effective role-level value
|
|
userVal: userMap[d.key], // undefined = no override
|
|
expiresAt: userExp[d.key] || null, // срок временного оверрайда (UTC) или null
|
|
effective: base[d.key] && (d.requires || []).every(r => !!base[r]),
|
|
}));
|
|
|
|
res.json({ role: target.role, permissions: result });
|
|
}
|
|
|
|
/* ── POST /api/permissions/users/:id { permission, enabled } ─────────── */
|
|
function setUserPermission(req, res) {
|
|
const uid = Number(req.params.id);
|
|
const { permission, enabled } = req.body;
|
|
const target = db.prepare('SELECT role FROM users WHERE id = ?').get(uid);
|
|
if (!target) return res.status(404).json({ error: 'User not found' });
|
|
if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === target.role))
|
|
return res.status(400).json({ error: 'Unknown permission for this role' });
|
|
const days = Number(req.body.days);
|
|
const hasExp = Number.isInteger(days) && days > 0; // временный оверрайд на N дней
|
|
db.transaction(() => {
|
|
if (hasExp) {
|
|
db.prepare(
|
|
"INSERT OR REPLACE INTO user_permissions (user_id, permission, enabled, expires_at) VALUES (?, ?, ?, datetime('now', ?))"
|
|
).run(uid, permission, enabled ? 1 : 0, '+' + days + ' days');
|
|
} else {
|
|
db.prepare(
|
|
'INSERT OR REPLACE INTO user_permissions (user_id, permission, enabled, expires_at) VALUES (?, ?, ?, NULL)'
|
|
).run(uid, permission, enabled ? 1 : 0);
|
|
}
|
|
// Invalidate existing JWT for this user immediately (точечно одному пользователю)
|
|
db.prepare(
|
|
'UPDATE users SET token_version = token_version + 1 WHERE id = ?'
|
|
).run(uid);
|
|
})();
|
|
audit(req, 'permission.user_set', `user:${uid}/${permission}`, `enabled=${enabled ? 1 : 0}${hasExp ? ` exp=+${days}d` : ''}`);
|
|
res.json({ ok: true, expires_in_days: hasExp ? days : null });
|
|
}
|
|
|
|
/* ── DELETE /api/permissions/users/:id/reset (single or all) ─────────── */
|
|
function resetUserPermissions(req, res) {
|
|
const uid = Number(req.params.id);
|
|
const { permission } = req.body; // optional: reset one key
|
|
db.transaction(() => {
|
|
if (permission) {
|
|
db.prepare(
|
|
'DELETE FROM user_permissions WHERE user_id = ? AND permission = ?'
|
|
).run(uid, permission);
|
|
} else {
|
|
db.prepare('DELETE FROM user_permissions WHERE user_id = ?').run(uid);
|
|
}
|
|
// Bump token_version so the user's JWT picks up the new effective permissions
|
|
// immediately (could be a downgrade if override was =1 and role default is =0).
|
|
db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?').run(uid);
|
|
})();
|
|
audit(req, 'permission.user_reset', `user:${uid}`, permission || null);
|
|
res.json({ ok: true });
|
|
}
|
|
|
|
/* ── Пресеты-профили (студенческие) — применяются к классу одним кликом ──── */
|
|
const PRESETS = {
|
|
student: [
|
|
{ id: 'full', label: 'Полный доступ', desc: 'Все возможности ученика включены',
|
|
perms: { 'tests.free': 1, 'board.post': 1, 'profile.edit': 1, 'shop.purchase': 1, 'gamification.challenges': 1, 'theory.access': 1, 'simulations.access': 1, 'simulations.quiz': 1 } },
|
|
{ id: 'focus', label: 'Режим фокуса', desc: 'Без магазина и испытаний — меньше отвлечений',
|
|
perms: { 'shop.purchase': 0, 'gamification.challenges': 0 } },
|
|
{ id: 'restricted', label: 'Ограниченный', desc: 'Без магазина, испытаний и лаборатории',
|
|
perms: { 'shop.purchase': 0, 'gamification.challenges': 0, 'simulations.access': 0 } },
|
|
{ id: 'reset', label: 'Сбросить к стандарту роли', desc: 'Снять все личные правила (наследование роли)',
|
|
perms: { 'tests.free': 'inherit', 'board.post': 'inherit', 'profile.edit': 'inherit', 'shop.purchase': 'inherit', 'gamification.challenges': 'inherit', 'theory.access': 'inherit', 'simulations.access': 'inherit', 'simulations.quiz': 'inherit' } },
|
|
],
|
|
};
|
|
|
|
/* Применить карту прав { key: 1|0|'inherit' } всем ученикам класса. → число затронутых. */
|
|
function applyPermsToClass(cid, permsMap) {
|
|
const members = db.prepare(`
|
|
SELECT u.id FROM class_members cm JOIN users u ON u.id = cm.user_id
|
|
WHERE cm.class_id = ? AND u.role IN ('student','free_student')`).all(cid);
|
|
const del = db.prepare('DELETE FROM user_permissions WHERE user_id = ? AND permission = ?');
|
|
const up = db.prepare('INSERT OR REPLACE INTO user_permissions (user_id, permission, enabled) VALUES (?, ?, ?)');
|
|
const bump = db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?');
|
|
db.transaction(() => {
|
|
for (const m of members) {
|
|
for (const [key, v] of Object.entries(permsMap)) {
|
|
if (v === 'inherit' || v === null) del.run(m.id, key);
|
|
else up.run(m.id, key, (v === 1 || v === true || v === '1') ? 1 : 0);
|
|
}
|
|
bump.run(m.id); // user-level: точечно обновляем сессию каждого затронутого ученика
|
|
}
|
|
})();
|
|
return members.length;
|
|
}
|
|
|
|
/* ── POST /api/permissions/class/:id/bulk { permission, enabled } ──────────
|
|
Выставить ОДНО личное правило сразу всем ученикам класса. */
|
|
function setClassPermission(req, res) {
|
|
const cid = Number(req.params.id);
|
|
const { permission } = req.body || {};
|
|
const { enabled } = req.body || {};
|
|
if (!Number.isInteger(cid) || cid <= 0) return res.status(400).json({ error: 'неверный id класса' });
|
|
if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === 'student'))
|
|
return res.status(400).json({ error: 'Unknown student permission' });
|
|
const inherit = (enabled === null || enabled === undefined || enabled === 'inherit');
|
|
const affected = applyPermsToClass(cid, { [permission]: inherit ? 'inherit' : ((enabled === 1 || enabled === true || enabled === '1') ? 1 : 0) });
|
|
audit(req, 'permission.class_bulk', `class:${cid}/${permission}`, inherit ? 'inherit' : `enabled=${enabled ? 1 : 0}`);
|
|
res.json({ ok: true, affected });
|
|
}
|
|
|
|
/* ── GET /api/permissions/presets → { student:[{id,label,desc,perms}] } ──── */
|
|
function getPresets(_req, res) { res.json(PRESETS); }
|
|
|
|
/* ── POST /api/permissions/class/:id/preset { preset } ─────────────────────
|
|
Применить пресет-профиль ко всем ученикам класса. */
|
|
function applyClassPreset(req, res) {
|
|
const cid = Number(req.params.id);
|
|
const { preset } = req.body || {};
|
|
if (!Number.isInteger(cid) || cid <= 0) return res.status(400).json({ error: 'неверный id класса' });
|
|
const p = PRESETS.student.find(x => x.id === preset);
|
|
if (!p) return res.status(400).json({ error: 'Unknown preset' });
|
|
const affected = applyPermsToClass(cid, p.perms);
|
|
audit(req, 'permission.class_preset', `class:${cid}`, p.id);
|
|
res.json({ ok: true, affected, preset: p.id });
|
|
}
|
|
|
|
/* ── GET /api/permissions/log?user_id= — история изменений прав (admin) ── */
|
|
function getPermissionLog(req, res) {
|
|
const uid = req.query.user_id ? Number(req.query.user_id) : null;
|
|
const rows = uid
|
|
? db.prepare(`
|
|
SELECT a.action, a.target, a.detail, a.created_at, u.name AS actor
|
|
FROM admin_audit_log a LEFT JOIN users u ON u.id = a.admin_id
|
|
WHERE a.action LIKE 'permission.user%' AND (a.target = ? OR a.target LIKE ?)
|
|
ORDER BY a.id DESC LIMIT 50`).all('user:' + uid, 'user:' + uid + '/%')
|
|
: db.prepare(`
|
|
SELECT a.action, a.target, a.detail, a.created_at, u.name AS actor
|
|
FROM admin_audit_log a LEFT JOIN users u ON u.id = a.admin_id
|
|
WHERE a.action = 'permission.set'
|
|
ORDER BY a.id DESC LIMIT 50`).all();
|
|
|
|
const labelOf = {};
|
|
for (const k of registry.listKeys()) labelOf[k] = registry.PERMISSIONS[k].label;
|
|
const roleName = (r) => (r === 'teacher' ? 'учитель' : r === 'student' ? 'ученик' : r);
|
|
const onoff = (d) => (/enabled=1/.test(d || '') ? 'включил' : /enabled=0/.test(d || '') ? 'выключил' : 'изменил');
|
|
|
|
const out = rows.map(r => {
|
|
let text;
|
|
if (r.action === 'permission.set') {
|
|
const m = /^role:([^/]+)\/(.+)$/.exec(r.target || '');
|
|
const key = m ? m[2] : '';
|
|
text = `${onoff(r.detail)} «${labelOf[key] || key}» для роли «${roleName(m ? m[1] : '')}»`;
|
|
} else if (r.action === 'permission.user_set') {
|
|
const m = /^user:\d+\/(.+)$/.exec(r.target || '');
|
|
const key = m ? m[1] : '';
|
|
text = `${onoff(r.detail)} личное «${labelOf[key] || key}»`;
|
|
} else { // permission.user_reset
|
|
text = r.detail ? `сбросил личное «${labelOf[r.detail] || r.detail}»` : 'сбросил все личные правила';
|
|
}
|
|
return { actor: r.actor || '—', text, at: r.created_at };
|
|
});
|
|
res.json(out);
|
|
}
|
|
|
|
module.exports = { getPermissions, setPermission, seedDefaults, ALL_PERMISSIONS, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions, getPermissionLog, setClassPermission, getPresets, applyClassPreset };
|