diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index ed6b3d6..6b330e2 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -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 ───────────────────────────────── */ diff --git a/backend/src/db/migrations/055_user_custom_role.sql b/backend/src/db/migrations/055_user_custom_role.sql new file mode 100644 index 0000000..21da666 --- /dev/null +++ b/backend/src/db/migrations/055_user_custom_role.sql @@ -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; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 9037518..dbf5bae 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -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 {} diff --git a/backend/tests/custom-roles.test.js b/backend/tests/custom-roles.test.js index 92e6280..4751dca 100644 --- a/backend/tests/custom-roles.test.js +++ b/backend/tests/custom-roles.test.js @@ -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); + }); +});