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

@ -8,11 +8,13 @@
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import require
from app.domain.roles import Role
from app.db.base import get_session
from app.db.models import Group, Score, Session, Trainee
@ -59,7 +61,10 @@ class ProfileOut(BaseModel):
@router.get("", response_model=list[TraineeOut])
async def listing(db: AsyncSession = Depends(get_session)) -> list[TraineeOut]:
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[TraineeOut]:
"""Список курсантов — преподавателю и администратору: обучающемуся он
не нужен, а чужие фамилии из него видны."""
require(request, Role.INSTRUCTOR, Role.ADMIN)
rows = await db.execute(
select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True)
)
@ -67,7 +72,12 @@ async def listing(db: AsyncSession = Depends(get_session)) -> list[TraineeOut]:
@router.get("/{trainee_id}/profile", response_model=ProfileOut)
async def profile(trainee_id: UUID, db: AsyncSession = Depends(get_session)) -> ProfileOut:
async def profile(
trainee_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
) -> ProfileOut:
who = require(request)
if who.role is Role.TRAINEE and who.trainee_id != trainee_id:
raise HTTPException(status_code=403, detail="not_your_profile")
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")