diff --git a/backend/src/controllers/permissionsController.js b/backend/src/controllers/permissionsController.js index 323f7d7..40e960a 100644 --- a/backend/src/controllers/permissionsController.js +++ b/backend/src/controllers/permissionsController.js @@ -60,11 +60,14 @@ function getMyPermissions(req, res) { if (role === 'admin') return res.json({ role, permissions: [] }); // admins bypass all seedDefaults(); - const roleRows = db.prepare( - 'SELECT permission, enabled FROM role_permissions WHERE role = ?' - ).all(role); + // База роли + наложение кастомной роли (если назначена): role_permissions[base] + // перекрываются role_permissions[customRole]. + const customRole = req.user.customRole || null; const roleMap = {}; - for (const r of roleRows) roleMap[r.permission] = r.enabled === 1; + 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'))" @@ -85,16 +88,16 @@ function getMyPermissions(req, res) { /* ── GET /api/permissions/users/:id ──────────────────────────────────── */ function getUserPermissions(req, res) { const uid = Number(req.params.id); - const target = db.prepare('SELECT id, role FROM users WHERE id = ?').get(uid); + 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 roleRows = db.prepare( - 'SELECT permission, enabled FROM role_permissions WHERE role = ?' - ).all(target.role); + // role-level values: база роли + наложение кастомной роли (если назначена) const roleMap = {}; - for (const r of roleRows) roleMap[r.permission] = r.enabled === 1; + 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( diff --git a/backend/src/db/migrations/056_role_permissions_any_role.sql b/backend/src/db/migrations/056_role_permissions_any_role.sql new file mode 100644 index 0000000..90e8783 --- /dev/null +++ b/backend/src/db/migrations/056_role_permissions_any_role.sql @@ -0,0 +1,19 @@ +-- 056_role_permissions_any_role.sql +-- Phase C, Stage C-3 — пер-ролевые права для КАСТОМНЫХ ролей. +-- Снимаем CHECK role IN ('teacher','student','free_student') с role_permissions, +-- чтобы хранить набор прав для произвольной кастомной роли (ключ role = имя роли). +-- SQLite не ALTER-ит CHECK → пересобираем таблицу (она мала, без входящих FK). + +CREATE TABLE role_permissions_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + role TEXT NOT NULL, + permission TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + UNIQUE (role, permission) +); + +INSERT INTO role_permissions_new (id, role, permission, enabled) + SELECT id, role, permission, enabled FROM role_permissions; + +DROP TABLE role_permissions; +ALTER TABLE role_permissions_new RENAME TO role_permissions; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index dbf5bae..56760c5 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -60,28 +60,32 @@ function requireRole(...roles) { } /* ── Разрешено ли ОДНО право: user override → role override → дефолт реестра ── */ -function isEnabled(uid, role, key) { - // Просроченный временный оверрайд (expires_at в прошлом) игнорируем — наследуем роль. +/* Разрешено ли ОДНО право: user override (без просрочки) → role_permissions[permRole] + → role_permissions[baseRole] (фолбэк для кастомной роли) → дефолт реестра(baseRole). + Для встроенной роли permRole === baseRole. */ +function isEnabled(uid, permRole, baseRole, key) { const userRow = db.prepare( "SELECT enabled FROM user_permissions WHERE user_id = ? AND permission = ? AND (expires_at IS NULL OR expires_at > datetime('now'))" ).get(uid, key); if (userRow !== undefined) return userRow.enabled === 1; - const roleRow = db.prepare( - 'SELECT enabled FROM role_permissions WHERE role = ? AND permission = ?' - ).get(role, key); - return roleRow !== undefined ? roleRow.enabled === 1 : (PERM_DEFAULTS[role]?.[key] ?? false); + let roleRow = db.prepare('SELECT enabled FROM role_permissions WHERE role = ? AND permission = ?').get(permRole, key); + if (roleRow === undefined && permRole !== baseRole) { + roleRow = db.prepare('SELECT enabled FROM role_permissions WHERE role = ? AND permission = ?').get(baseRole, key); + } + return roleRow !== undefined ? roleRow.enabled === 1 : (PERM_DEFAULTS[baseRole]?.[key] ?? false); } /* ── Проверка права с учётом зависимостей (requires): own AND все requires ── */ function requirePermission(key) { return (req, res, next) => { if (req.user?.role === 'admin') return next(); - const role = req.user?.role; + const baseRole = req.user?.role; const uid = req.user?.id; - if (!role) return res.status(401).json({ error: 'Unauthorized' }); + if (!baseRole) return res.status(401).json({ error: 'Unauthorized' }); + const permRole = req.user?.customRole || baseRole; // кастомная роль конфигурит права своим именем const reqs = (registry.PERMISSIONS[key] && registry.PERMISSIONS[key].requires) || []; - const ok = isEnabled(uid, role, key) && reqs.every(r => isEnabled(uid, role, r)); + const ok = isEnabled(uid, permRole, baseRole, key) && reqs.every(r => isEnabled(uid, permRole, baseRole, r)); if (ok) return next(); logDenied(req, 'perm_denied', key); return res.status(403).json({ error: 'Permission denied' }); diff --git a/backend/tests/custom-roles.test.js b/backend/tests/custom-roles.test.js index 4751dca..f1247e4 100644 --- a/backend/tests/custom-roles.test.js +++ b/backend/tests/custom-roles.test.js @@ -84,3 +84,25 @@ describe('custom roles — назначение пользователю (C-2)', assert.equal(bad.status, 400); }); }); + +describe('custom roles — пер-ролевые права (C-3)', () => { + let adminToken, mUser; + before(async () => { + adminToken = (await getToken('admin')).token; + db.prepare("INSERT OR IGNORE INTO roles (name,label,base_roles,is_builtin) VALUES ('methodist2','Методист 2',?,0)") + .run(JSON.stringify(['teacher'])); + mUser = await getToken('student'); + db.prepare("UPDATE users SET role='teacher', custom_role='methodist2' WHERE id=?").run(mUser.userId); + // кастомная роль ВКЛЮЧАЕТ questions.manage (у teacher по умолчанию 0) + db.prepare("INSERT OR REPLACE INTO role_permissions (role,permission,enabled) VALUES ('methodist2','questions.manage',1)").run(); + }); + + it('права кастомной роли перекрывают базу; фолбэк на базу для прочих ключей', async () => { + const view = await inject('GET', `/api/permissions/users/${mUser.userId}`, null, adminToken); + assert.equal(view.status, 200, JSON.stringify(view.body)); + const qm = view.body.permissions.find(p => p.key === 'questions.manage'); + const cm = view.body.permissions.find(p => p.key === 'classes.manage'); + assert.equal(qm.effective, true, 'questions.manage включён кастомной ролью (база teacher=0)'); + assert.equal(cm.effective, true, 'classes.manage — по фолбэку базы teacher=1'); + }); +});