feat(access): Фаза 2c — история правил + пресет «копировать доступ из класса»

История: GET /api/access/log (admin-only) — кто/когда открыл/закрыл/сбросил
правило для контента (из admin_audit_log, имена классов/учеников резолвятся).
Клиент LS.accessLog; в режиме «По контенту» — кнопка «История изменений».
Пресет: в режиме «По классу» — «Скопировать доступ из класса [выбор]» (дополняет
текущие правила открытыми правилами класса-источника). Тест: история (admin
видит запись, учителю 403). content-access 13/13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 13:55:02 +03:00
parent 11ec350dfa
commit b702b04ed2
4 changed files with 102 additions and 2 deletions
+30
View File
@@ -146,6 +146,36 @@ router.get('/matrix', (req, res) => {
res.json({ classes, open });
});
/* ── История изменений правил доступа к контенту (только админ) ────────── */
/* GET /api/access/log?content_type=&content_ref=
→ [{ action:'grant'|'deny'|'inherit', actor, targetName, at }] (последние 50) */
router.get('/log', requireRole('admin'), (req, res) => {
const { content_type, content_ref } = req.query;
if (!['textbook', 'exam', 'sim', 'course'].includes(content_type) || !content_ref) {
return res.status(400).json({ error: 'content_type и content_ref обязательны' });
}
const rows = db.prepare(`
SELECT a.action, a.detail, a.created_at, u.name AS actor
FROM admin_audit_log a LEFT JOIN users u ON u.id = a.admin_id
WHERE a.action LIKE 'access.%' AND a.target = ?
ORDER BY a.id DESC LIMIT 50
`).all(content_type + ':' + content_ref);
const out = rows.map(r => {
const [scope, tid] = String(r.detail || '').split(':');
let targetName = r.detail || '';
if (scope === 'class') {
const c = db.prepare('SELECT name FROM classes WHERE id = ?').get(tid);
targetName = c ? `класс «${c.name}»` : `класс #${tid}`;
} else if (scope === 'student') {
const s = db.prepare('SELECT name, email FROM users WHERE id = ?').get(tid);
targetName = s ? `ученик ${s.name || s.email}` : `ученик #${tid}`;
}
return { action: r.action.replace('access.', ''), actor: r.actor || '—', targetName, at: r.created_at };
});
res.json(out);
});
/* ── Текущие правила для одного контента ───────────────────────────────── */
/* GET /api/access/rules?content_type=&content_ref=
→ { classRules:{[class_id]:allow}, studentRules:{[user_id]:allow} } */
+13
View File
@@ -116,6 +116,19 @@ describe('contentAccess', () => {
assert.ok(Array.isArray(r.body.sims) && r.body.sims.length >= 1, 'каталог содержит симуляции');
});
it('GET /api/access/log — история (admin видит запись; учителю 403)', async () => {
const admin = await getToken('admin');
const p = await inject('POST', '/api/access/rules',
{ content_type: 'textbook', content_ref: HUB, scope: 'class', target_id: classId, allow: 1 }, admin.token);
assert.ok(p.status < 300, JSON.stringify(p.body));
const log = await inject('GET', `/api/access/log?content_type=textbook&content_ref=${HUB}`, null, admin.token);
assert.equal(log.status, 200);
assert.ok(Array.isArray(log.body) && log.body.length >= 1, 'есть запись истории');
assert.equal(log.body[0].action, 'grant');
const t = await inject('GET', `/api/access/log?content_type=textbook&content_ref=${HUB}`, null, teacher.token);
assert.equal(t.status, 403, 'учителю недоступно');
});
it('DELETE /api/classes/:id чистит правила класса (через purgeAccessFor)', async () => {
setRule('class', classId, 1);
const del = await inject('DELETE', `/api/classes/${classId}`, null, teacher.token);