feat(access): Фаза 0 — целостность правил доступа + подтверждение массового закрытия

- contentAccess.purgeAccessFor(scope,id) — единая точка очистки content_access
  (нет FK). deleteClass и _deleteUserTx переведены на неё (убрано дублирование).
- Админ-UI: confirm() перед «Закрыть у всех / Закрыть весь» (необратимая массовая
  операция больше не срабатывает мгновенно).
- Новый тест content-access.test.js (9/9): allowlist, ученик>класс, наследование
  главой хаба, admin/teacher bypass, allowedRefs/filterTextbooks, purgeAccessFor,
  чистка правил при DELETE класса. Полный backend-набор: 203/206 (3 — baseline Auth).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 12:39:08 +03:00
parent edb98895df
commit 1bbddc00c8
5 changed files with 126 additions and 4 deletions
+3 -2
View File
@@ -1,6 +1,7 @@
const db = require('../db/db'); const db = require('../db/db');
const { stripTags } = require('../utils/sanitize'); const { stripTags } = require('../utils/sanitize');
const { audit } = require('../utils/audit'); const { audit } = require('../utils/audit');
const { purgeAccessFor } = require('../services/contentAccess');
/* ── Prepared statements ──────────────────────────────────────────────── */ /* ── Prepared statements ──────────────────────────────────────────────── */
const stmts = { const stmts = {
@@ -480,8 +481,8 @@ const _deleteUserTx = db.transaction((uid) => {
// The rest cascades via ON DELETE CASCADE, but explicitly clean large tables: // The rest cascades via ON DELETE CASCADE, but explicitly clean large tables:
db.prepare('DELETE FROM notifications WHERE user_id = ?').run(uid); db.prepare('DELETE FROM notifications WHERE user_id = ?').run(uid);
db.prepare('DELETE FROM test_sessions WHERE user_id = ?').run(uid); db.prepare('DELETE FROM test_sessions WHERE user_id = ?').run(uid);
// Персональные правила доступа к контенту (нет FK — чистим вручную): // Персональные правила доступа к контенту (нет FK — единая чистка):
db.prepare("DELETE FROM content_access WHERE scope = 'student' AND target_id = ?").run(uid); purgeAccessFor('student', uid);
db.prepare('DELETE FROM users WHERE id = ?').run(uid); db.prepare('DELETE FROM users WHERE id = ?').run(uid);
}); });
+3 -2
View File
@@ -3,6 +3,7 @@ const crypto = require('crypto');
const { onClassJoined } = require('./gamificationController'); const { onClassJoined } = require('./gamificationController');
const { pushNotif } = require('../utils/notifications'); const { pushNotif } = require('../utils/notifications');
const { stripTags } = require('../utils/sanitize'); const { stripTags } = require('../utils/sanitize');
const { purgeAccessFor } = require('../services/contentAccess');
function genCode() { function genCode() {
return crypto.randomBytes(4).toString('hex').toUpperCase(); return crypto.randomBytes(4).toString('hex').toUpperCase();
@@ -324,8 +325,8 @@ function deleteClass(req, res) {
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id) if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' }); return res.status(403).json({ error: 'Forbidden' });
stmts.deleteClass.run(req.params.id); stmts.deleteClass.run(req.params.id);
// Правила доступа к контенту для этого класса (нет FK — чистим вручную): // Правила доступа к контенту для этого класса (нет FK — единая чистка):
db.prepare("DELETE FROM content_access WHERE scope = 'class' AND target_id = ?").run(req.params.id); purgeAccessFor('class', req.params.id);
res.json({ ok: true }); res.json({ ok: true });
} }
+14
View File
@@ -84,6 +84,19 @@ function filterTextbooks(user, rows) {
return rows.filter(r => allow.has(r.slug)); return rows.filter(r => allow.has(r.slug));
} }
/* ── Очистка правил (единая точка; у content_access нет FK) ───────────────
* Вызывать из ВСЕХ путей удаления цели, чтобы не оставлять осиротевших правил:
* purgeAccessFor('class', classId) — при удалении класса
* purgeAccessFor('student', userId) — при удалении пользователя
* Возвращает число удалённых строк. */
const _purgeClass = db.prepare("DELETE FROM content_access WHERE scope = 'class' AND target_id = ?");
const _purgeStudent = db.prepare("DELETE FROM content_access WHERE scope = 'student' AND target_id = ?");
function purgeAccessFor(scope, id) {
if (scope === 'class') return _purgeClass.run(id).changes;
if (scope === 'student') return _purgeStudent.run(id).changes;
return 0;
}
module.exports = { module.exports = {
PRIVILEGED, PRIVILEGED,
textbookAccessKey, textbookAccessKey,
@@ -92,4 +105,5 @@ module.exports = {
canAccessExam, canAccessExam,
allowedRefs, allowedRefs,
filterTextbooks, filterTextbooks,
purgeAccessFor,
}; };
+104
View File
@@ -0,0 +1,104 @@
'use strict';
/**
* contentAccess — резолвинг доступа (allowlist) + целостность чистки правил.
* Модель: по умолчанию закрыто; правило ученика важнее правила класса; глава
* наследует доступ хаба; admin/teacher проходят всегда. purgeAccessFor — единая
* точка очистки; deleteClass обязан её вызывать (нет FK у content_access).
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { db, getToken, inject, cleanup } = require('./setup');
const access = require('../src/services/contentAccess');
after(() => cleanup());
describe('contentAccess', () => {
let teacher, student, classId;
const HUB = 'acc-test-hub', CH = 'acc-test-hub-ch1';
before(async () => {
teacher = await getToken('teacher');
student = await getToken('student');
const ins = db.prepare(`INSERT INTO textbooks
(slug,subject,grade,title,author,description,html_path,para_count,color,sort_order,is_active,parent_slug)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`);
ins.run(HUB, 'math', 5, 'Acc Test Hub', '', '', 'acc_hub.html', 2, 'indigo', 99, 1, null);
ins.run(CH, 'math', 5, 'Acc Test Ch1', '', '', 'acc_ch1.html', 2, 'indigo', 1, 1, HUB);
const r = await inject('POST', '/api/classes', { name: 'AccTest Class' }, teacher.token);
assert.ok(r.status < 300, 'класс создан: ' + JSON.stringify(r.body));
classId = db.prepare('SELECT id FROM classes WHERE name = ?').get('AccTest Class').id;
const m = await inject('POST', `/api/classes/${classId}/members`, { user_id: student.userId }, teacher.token);
assert.ok(m.status < 300, 'ученик добавлен: ' + JSON.stringify(m.body));
});
const stu = () => ({ id: student.userId, role: 'student' });
function setRule(scope, target, allow) {
db.prepare(`INSERT INTO content_access (content_type,content_ref,scope,target_id,allow)
VALUES ('textbook',?,?,?,?)
ON CONFLICT(content_type,content_ref,scope,target_id) DO UPDATE SET allow=excluded.allow`)
.run(HUB, scope, target, allow);
}
const clearHub = () => db.prepare("DELETE FROM content_access WHERE content_ref=?").run(HUB);
it('default deny — без правил доступа нет', () => {
clearHub();
assert.equal(access.canAccessTextbook(stu(), HUB), false);
});
it('правило класса allow=1 открывает хаб и главу (наследование)', () => {
setRule('class', classId, 1);
assert.equal(access.canAccessTextbook(stu(), HUB), true);
assert.equal(access.canAccessTextbook(stu(), CH), true);
});
it('правило ученика allow=0 перекрывает разрешение класса', () => {
setRule('class', classId, 1);
setRule('student', student.userId, 0);
assert.equal(access.canAccessTextbook(stu(), HUB), false);
});
it('правило ученика allow=1 даёт доступ без класса', () => {
clearHub();
setRule('student', student.userId, 1);
assert.equal(access.canAccessTextbook(stu(), HUB), true);
});
it('admin и teacher проходят всегда', () => {
clearHub();
assert.equal(access.canAccessTextbook({ id: teacher.userId, role: 'teacher' }, HUB), true);
assert.equal(access.canAccessTextbook({ role: 'admin' }, HUB), true);
});
it('allowedRefs / filterTextbooks учитывают правила', () => {
clearHub();
setRule('class', classId, 1);
assert.ok(access.allowedRefs(student.userId, 'textbook').has(HUB));
const filtered = access.filterTextbooks(stu(), [{ slug: HUB }, { slug: 'nope' }]);
assert.deepEqual(filtered.map(r => r.slug), [HUB]);
});
it('purgeAccessFor(class) удаляет правила класса', () => {
clearHub();
setRule('class', classId, 1);
const n = access.purgeAccessFor('class', classId);
assert.ok(n >= 1, 'удалено хотя бы одно');
assert.equal(db.prepare("SELECT COUNT(*) c FROM content_access WHERE scope='class' AND target_id=?").get(classId).c, 0);
});
it('purgeAccessFor(student) удаляет персональные правила', () => {
clearHub();
setRule('student', student.userId, 1);
access.purgeAccessFor('student', student.userId);
assert.equal(db.prepare("SELECT COUNT(*) c FROM content_access WHERE scope='student' AND target_id=?").get(student.userId).c, 0);
});
it('DELETE /api/classes/:id чистит правила класса (через purgeAccessFor)', async () => {
setRule('class', classId, 1);
const del = await inject('DELETE', `/api/classes/${classId}`, null, teacher.token);
assert.ok(del.status < 300, 'класс удалён: ' + JSON.stringify(del.body));
assert.equal(db.prepare("SELECT COUNT(*) c FROM content_access WHERE scope='class' AND target_id=?").get(classId).c, 0);
});
});
+2
View File
@@ -223,6 +223,7 @@
} }
async function bulk(allow) { async function bulk(allow) {
if (!allow && !confirm(`Закрыть «${_selContent.title}» у всех классов?`)) return;
const classes = _targets.classes || []; const classes = _targets.classes || [];
try { try {
await Promise.all(classes.map(c => await Promise.all(classes.map(c =>
@@ -310,6 +311,7 @@
} }
async function classBulk(allow) { async function classBulk(allow) {
if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return;
const all = [...(_catalog.textbooks || []).map(it => ['textbook', it[keyName('textbook')]]), const all = [...(_catalog.textbooks || []).map(it => ['textbook', it[keyName('textbook')]]),
...(_catalog.exams || []).map(it => ['exam', it[keyName('exam')]])]; ...(_catalog.exams || []).map(it => ['exam', it[keyName('exam')]])];
try { try {