feat: вход, роли и аудит действий (lct-23)

Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.

- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
  cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
  отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
  call и station — обучающемуся и преподавателю. Отказ приходит событием
  error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
  идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
  к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
  зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.

Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.

Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.

181 тест зелёный (14 новых), make typecheck чистый.
This commit is contained in:
Ivan Gerasimov 2026-09-20 09:01:05 +03:00
commit d55c8bc8cd
30 changed files with 984 additions and 21 deletions

View file

@ -16,6 +16,8 @@ from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.domain.events import ErrorEvent, ErrorKind
from app.api.auth import principal_of
from app.domain.roles import Role
from app.session.hub import hub
router = APIRouter()
@ -36,10 +38,24 @@ async def _wait_for_disconnect(ws: WebSocket) -> None:
return
async def _reject(ws: WebSocket, message: str) -> None:
"""Отказ до входа в цикл: сокет закрывается с объяснением, а не молча."""
await ws.send_text(
ErrorEvent(code=ErrorKind.FORBIDDEN, message=message).model_dump_json()
)
await ws.close()
@router.websocket("/ws/observe/{session_id}")
async def observe(ws: WebSocket, session_id: UUID) -> None:
await ws.accept()
# Наблюдение за чужим занятием — не для обучающегося.
who = principal_of(ws)
if who is None or who.role not in (Role.INSTRUCTOR, Role.ADMIN):
await _reject(ws, "Недостаточно прав для этого экрана")
return
state = hub.get(session_id)
if state is None:
await ws.send_text(