feat(permissions): C-2 — присвоение кастомной роли пользователю (users.custom_role)
Миграция 055: ADD COLUMN users.custom_role (безопасно, без пересборки users). Модель: users.role = функциональная база (встроенная, CHECK ок, драйвит ветки контроллеров и резолв прав), users.custom_role = имя кастомной роли. updateRole (PATCH /api/admin/users/:id/role) принимает кастомные роли → ставит base_roles[0] как базу + custom_role=имя; встроенная → custom_role=NULL; неизвестная → 400. authMiddleware/optionalAuth читают custom_role → req.user.customRole; requireRole расширяет до effectiveRoles(customRole||role). Тесты custom-roles 7/7; backend без регрессий. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -243,9 +243,20 @@ function getUsers(req, res) {
|
||||
/* ── PATCH /api/admin/users/:id/role ─────────────────────────────────── */
|
||||
function updateRole(req, res) {
|
||||
const { role } = req.body;
|
||||
const allowed = ['student', 'teacher', 'admin', 'free_student'];
|
||||
if (!allowed.includes(role))
|
||||
return res.status(400).json({ error: `role must be one of: ${allowed.join(', ')}` });
|
||||
const BUILTIN = ['student', 'teacher', 'admin', 'free_student'];
|
||||
|
||||
// Кастомная роль: хранится как функциональная база (base_roles[0]) в users.role
|
||||
// + имя в users.custom_role. Встроенная роль: role = built-in, custom_role = NULL.
|
||||
let baseRole = role, customRole = null;
|
||||
if (!BUILTIN.includes(role)) {
|
||||
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: `Unknown role: ${role}` });
|
||||
let bases = [];
|
||||
try { bases = JSON.parse(cr.base_roles || '[]'); } catch (_e) { bases = []; }
|
||||
baseRole = bases.find(b => BUILTIN.includes(b)) || 'student';
|
||||
customRole = role;
|
||||
}
|
||||
|
||||
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
@@ -254,9 +265,10 @@ function updateRole(req, res) {
|
||||
return res.status(400).json({ error: 'Cannot change your own role' });
|
||||
|
||||
const oldRole = db.prepare('SELECT role FROM users WHERE id = ?').get(req.params.id)?.role;
|
||||
db.prepare('UPDATE users SET role = ?, token_version = token_version + 1 WHERE id = ?').run(role, req.params.id);
|
||||
audit(req, 'user.role_change', `user:${req.params.id}`, `${oldRole} -> ${role}`);
|
||||
res.json({ id: Number(req.params.id), role });
|
||||
db.prepare('UPDATE users SET role = ?, custom_role = ?, token_version = token_version + 1 WHERE id = ?')
|
||||
.run(baseRole, customRole, req.params.id);
|
||||
audit(req, 'user.role_change', `user:${req.params.id}`, `${oldRole} -> ${customRole || baseRole}`);
|
||||
res.json({ id: Number(req.params.id), role: customRole || baseRole, base: baseRole });
|
||||
}
|
||||
|
||||
/* ── GET /api/admin/users/:id/sessions ───────────────────────────────── */
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 055_user_custom_role.sql
|
||||
-- Phase C, Stage C-2 — присвоение кастомной роли пользователю.
|
||||
-- users.role в канон-схеме имеет CHECK (admin/teacher/student/free_student) и
|
||||
-- безопасно пересобрать users нельзя (FK многих таблиц, миграции в транзакции).
|
||||
-- Поэтому кастомную роль храним отдельной колонкой: users.role = функциональная
|
||||
-- БАЗА (встроенная роль для веток контроллеров и резолва прав), users.custom_role
|
||||
-- = имя кастомной роли (для гейтов через effectiveRoles + метка + C-3 пер-ролевые права).
|
||||
-- NULL = обычная встроенная роль.
|
||||
|
||||
ALTER TABLE users ADD COLUMN custom_role TEXT;
|
||||
@@ -16,7 +16,7 @@ function authMiddleware(req, res, next) {
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
// Re-fetch role + token_version from DB so changes take effect immediately
|
||||
const fresh = db.prepare('SELECT role, token_version, is_banned FROM users WHERE id = ?').get(payload.id);
|
||||
const fresh = db.prepare('SELECT role, custom_role, token_version, is_banned FROM users WHERE id = ?').get(payload.id);
|
||||
if (!fresh) return res.status(401).json({ error: 'User not found' });
|
||||
if (fresh.is_banned) return res.status(403).json({ error: 'Аккаунт заблокирован' });
|
||||
// Invalidate tokens issued before password change / role change.
|
||||
@@ -25,7 +25,7 @@ function authMiddleware(req, res, next) {
|
||||
if (fresh.token_version != null && payload.tv !== fresh.token_version) {
|
||||
return res.status(401).json({ error: 'Token revoked — please log in again' });
|
||||
}
|
||||
req.user = { ...payload, role: fresh.role };
|
||||
req.user = { ...payload, role: fresh.role, customRole: fresh.custom_role || null };
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Token invalid or expired' });
|
||||
@@ -52,7 +52,7 @@ function effectiveRoles(role) {
|
||||
|
||||
function requireRole(...roles) {
|
||||
return (req, res, next) => {
|
||||
const eff = effectiveRoles(req.user?.role);
|
||||
const eff = effectiveRoles(req.user?.customRole || req.user?.role);
|
||||
if (eff.some(r => roles.includes(r))) return next();
|
||||
if (req.user) logDenied(req, 'forbidden', `роль ${req.user.role}; требуется ${roles.join('/')}`);
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
@@ -137,10 +137,10 @@ function optionalAuth(req, res, next) {
|
||||
const token = header.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
const fresh = db.prepare('SELECT role, token_version, is_banned FROM users WHERE id = ?').get(payload.id);
|
||||
const fresh = db.prepare('SELECT role, custom_role, token_version, is_banned FROM users WHERE id = ?').get(payload.id);
|
||||
if (fresh && !fresh.is_banned) {
|
||||
if (fresh.token_version == null || payload.tv === fresh.token_version) {
|
||||
req.user = { ...payload, role: fresh.role };
|
||||
req.user = { ...payload, role: fresh.role, customRole: fresh.custom_role || null };
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { db, cleanup } = require('./setup');
|
||||
const { db, getToken, inject, cleanup } = require('./setup');
|
||||
const auth = require('../src/middleware/auth');
|
||||
|
||||
after(() => cleanup());
|
||||
@@ -52,3 +52,35 @@ describe('custom roles — effectiveRoles (C-1)', () => {
|
||||
assert.equal(passes('student', ['teacher', 'admin']), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom roles — назначение пользователю (C-2)', () => {
|
||||
let adminToken;
|
||||
before(async () => {
|
||||
adminToken = (await getToken('admin')).token;
|
||||
db.prepare("INSERT OR IGNORE INTO roles (name,label,base_roles,is_builtin) VALUES ('methodist','Методист',?,0)")
|
||||
.run(JSON.stringify(['teacher']));
|
||||
});
|
||||
|
||||
it('PATCH role=кастомная → users.role=база, custom_role=имя', async () => {
|
||||
const u = await getToken('student');
|
||||
const r = await inject('PATCH', `/api/admin/users/${u.userId}/role`, { role: 'methodist' }, adminToken);
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.equal(r.body.base, 'teacher');
|
||||
const row = db.prepare('SELECT role, custom_role FROM users WHERE id=?').get(u.userId);
|
||||
assert.equal(row.role, 'teacher', 'функциональная база = teacher');
|
||||
assert.equal(row.custom_role, 'methodist');
|
||||
|
||||
// обратно на встроенную → custom_role очищается
|
||||
const r2 = await inject('PATCH', `/api/admin/users/${u.userId}/role`, { role: 'student' }, adminToken);
|
||||
assert.equal(r2.status, 200);
|
||||
const row2 = db.prepare('SELECT role, custom_role FROM users WHERE id=?').get(u.userId);
|
||||
assert.equal(row2.role, 'student');
|
||||
assert.equal(row2.custom_role, null);
|
||||
});
|
||||
|
||||
it('PATCH с несуществующей ролью → 400', async () => {
|
||||
const u = await getToken('student');
|
||||
const bad = await inject('PATCH', `/api/admin/users/${u.userId}/role`, { role: 'ghostrole' }, adminToken);
|
||||
assert.equal(bad.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user