feat(permissions): C-3 — пер-ролевые права кастомных ролей (резолвер + конфиг)
Миграция 056: снят CHECK с role_permissions.role (пересборка) → можно хранить набор прав произвольной кастомной роли. isEnabled(uid,permRole,baseRole,key): user override → role_permissions[customRole] → фолбэк role_permissions[base] → дефолт реестра(base). requirePermission передаёт permRole=customRole||role. getMyPermissions/getUserPermissions: roleMap = база + наложение кастомной роли. Тест C-3: права кастомной роли перекрывают базу, фолбэк на базу. custom-roles 8/8, permissions 17/17, backend без регрессий. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
@@ -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' });
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user