feat(permissions): C-1 — фундамент кастомных ролей (roles table + наследование гейтов)
Phase C, Stage C-1 (ветка feature/custom-roles): таблица roles (name, label, base_roles JSON, is_builtin) + засев встроенных. auth.effectiveRoles(role) — кастомная роль наследует base_roles (какие встроенные гейты проходит); встроенные — быстрый путь без БД. requireRole() теперь проверяет пересечение allowed с effectiveRoles → 111 существующих гейтов не задеты (встроенные ведут себя как прежде). Дизайн: PHASE_C_DESIGN.md. Тест effectiveRoles 5/5; полный backend pass. ВАЖНО (обнаружено): users.role в канон-схеме имеет CHECK (admin/teacher/student/ free_student), безопасно пересобрать users (FK от многих таблиц, миграции в txn) нельзя → присвоение кастомной роли пользователю пойдёт через users.custom_role (C-2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
-- 054_roles.sql
|
||||
-- Phase C (кастомные роли), Stage C-1 — реестр ролей.
|
||||
-- Модель: роль наследует «базовые роли» (base_roles) — какие встроенные гейты
|
||||
-- requireRole она проходит. Встроенные роли наследуют сами себя. Кастомную роль
|
||||
-- админ создаёт со своим набором base_roles (доступ) и набором прав (см. C-2).
|
||||
--
|
||||
-- users.role хранит ИМЯ роли (CHECK на users.role нет — новое имя допустимо).
|
||||
-- requireRole() расширяет роль пользователя до effectiveRoles по этой таблице.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
name TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
base_roles TEXT NOT NULL DEFAULT '[]', -- JSON-массив встроенных ролей, чьи гейты проходит
|
||||
is_builtin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO roles (name, label, base_roles, is_builtin) VALUES
|
||||
('admin', 'Администратор', '["admin"]', 1),
|
||||
('teacher', 'Учитель', '["teacher"]', 1),
|
||||
('student', 'Ученик', '["student"]', 1),
|
||||
('free_student', 'Свободный ученик', '["free_student","student"]', 1);
|
||||
@@ -32,13 +32,30 @@ function authMiddleware(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Кастомные роли наследуют «базовые роли» (какие встроенные гейты проходят).
|
||||
Встроенные роли — быстрый путь без обращения к БД. */
|
||||
const BUILTIN_ROLES = new Set(['admin', 'teacher', 'student', 'free_student']);
|
||||
let _roleBaseStmt = null;
|
||||
function effectiveRoles(role) {
|
||||
if (!role) return [];
|
||||
if (BUILTIN_ROLES.has(role)) return [role];
|
||||
try {
|
||||
if (!_roleBaseStmt) _roleBaseStmt = db.prepare('SELECT base_roles FROM roles WHERE name = ?');
|
||||
const row = _roleBaseStmt.get(role);
|
||||
if (row && row.base_roles) {
|
||||
const arr = JSON.parse(row.base_roles);
|
||||
if (Array.isArray(arr) && arr.length) return arr.indexOf(role) >= 0 ? arr : arr.concat(role);
|
||||
}
|
||||
} catch (_e) { /* таблицы roles может не быть на старом инстансе — деградация к самой роли */ }
|
||||
return [role];
|
||||
}
|
||||
|
||||
function requireRole(...roles) {
|
||||
return (req, res, next) => {
|
||||
if (!roles.includes(req.user?.role)) {
|
||||
if (req.user) logDenied(req, 'forbidden', `роль ${req.user.role}; требуется ${roles.join('/')}`);
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
next();
|
||||
const eff = effectiveRoles(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' });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,4 +147,4 @@ function optionalAuth(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, perm, parentAuth };
|
||||
module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, perm, parentAuth, effectiveRoles };
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Phase C, Stage C-1 (фундамент): таблица roles + effectiveRoles() — кастомная роль
|
||||
* наследует «базовые роли» (какие встроенные гейты requireRole проходит).
|
||||
* Здесь тестируем ядро (effectiveRoles) и поведение requireRole-мидлвари на нём.
|
||||
* Присвоение кастомной роли пользователю (users.custom_role) — Stage C-2
|
||||
* (в канонической схеме у users.role есть CHECK, прямое присвоение невозможно).
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { db, cleanup } = require('./setup');
|
||||
const auth = require('../src/middleware/auth');
|
||||
|
||||
after(() => cleanup());
|
||||
|
||||
describe('custom roles — effectiveRoles (C-1)', () => {
|
||||
before(() => {
|
||||
const ins = db.prepare("INSERT OR IGNORE INTO roles (name,label,base_roles,is_builtin) VALUES (?,?,?,0)");
|
||||
ins.run('methodist', 'Методист', JSON.stringify(['teacher']));
|
||||
ins.run('guestx', 'Гость', JSON.stringify([]));
|
||||
});
|
||||
|
||||
it('встроенная роль раскрывается в саму себя (без БД)', () => {
|
||||
assert.deepEqual(auth.effectiveRoles('teacher'), ['teacher']);
|
||||
assert.deepEqual(auth.effectiveRoles('admin'), ['admin']);
|
||||
});
|
||||
|
||||
it('кастомная роль наследует base_roles + себя', () => {
|
||||
assert.deepEqual(auth.effectiveRoles('methodist').sort(), ['methodist', 'teacher']);
|
||||
});
|
||||
|
||||
it('кастомная роль без base = только она сама', () => {
|
||||
assert.deepEqual(auth.effectiveRoles('guestx'), ['guestx']);
|
||||
});
|
||||
|
||||
it('неизвестная роль = только она сама', () => {
|
||||
assert.deepEqual(auth.effectiveRoles('nope'), ['nope']);
|
||||
});
|
||||
|
||||
// requireRole-мидлварь на основе effectiveRoles: allow-путь
|
||||
function passes(role, allowed) {
|
||||
let ok = false;
|
||||
const res = { status: () => ({ json: () => {} }) };
|
||||
auth.requireRole(...allowed)({ user: { role, id: 1 }, ip: '127.0.0.1', method: 'GET', originalUrl: '/t', headers: {} }, res, () => { ok = true; });
|
||||
return ok;
|
||||
}
|
||||
|
||||
it('requireRole пропускает кастомную роль по наследованию', () => {
|
||||
assert.equal(passes('methodist', ['teacher', 'admin']), true);
|
||||
assert.equal(passes('guestx', ['teacher', 'admin']), false);
|
||||
assert.equal(passes('teacher', ['teacher', 'admin']), true);
|
||||
assert.equal(passes('student', ['teacher', 'admin']), false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user