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

@ -7,15 +7,17 @@
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
from app.db import repo
from app.db.base import get_session
from app.domain.events import SessionMode, SessionReport
from app.scenarios import store
from app.scoring.report import build as build_report
from app.domain.roles import Role
from app.session.hub import hub
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
@ -123,22 +125,29 @@ def _live(session_id: UUID):
@router.get("/{session_id}/report", response_model=SessionReport)
async def report(session_id: UUID) -> SessionReport:
async def report(session_id: UUID, request: Request) -> SessionReport:
"""Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки.
Оценка курсанту открывается событием `score.ready` после самооценки; здесь
прав нет — в прототипе нет входа, и точка доступна всем, у кого есть номер
занятия (docs/arch/CONTRACT.md).
Обучающийся открывает только свой разбор: ТЗ запрещает доступ к чужим
результатам, а не только к чужим экранам. Проверка по `trainee_id`
занятия, а не по номеру в ссылке.
"""
who = require(request)
state, scenario = _live(session_id)
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
raise HTTPException(status_code=403, detail="not_your_session")
if state.score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
return build_report(session_id, state, scenario)
@router.patch("/{session_id}/report", response_model=SessionReport)
async def override(session_id: UUID, body: ScoreOverride) -> SessionReport:
"""Тренажёр готовит материал, преподаватель имеет последнее слово."""
async def override(session_id: UUID, body: ScoreOverride, request: Request) -> SessionReport:
"""Тренажёр готовит материал, преподаватель имеет последнее слово.
Администратору сюда нельзя: ТЗ запрещает ему вмешиваться в оценки прямо.
"""
who = require(request, Role.INSTRUCTOR)
state, scenario = _live(session_id)
if state.score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
@ -148,11 +157,16 @@ async def override(session_id: UUID, body: ScoreOverride) -> SessionReport:
"overridden_by": body.author,
"override_comment": body.comment,
}
await audit(
who.login, who.role.value, "score.override", str(session_id),
f"{state.score.get('score_auto')} → {body.score_final}: {body.comment}",
)
return build_report(session_id, state, scenario)
@router.get("", response_model=list[SessionOut])
async def listing(
request: Request,
trainee: UUID | None = None,
group: UUID | None = None,
mode: SessionMode | None = None,
@ -160,6 +174,10 @@ async def listing(
limit: int = 100,
db: AsyncSession = Depends(get_session),
) -> list[SessionOut]:
who = require(request)
# Обучающийся видит только свою историю, что бы он ни передал в фильтре.
if who.role is Role.TRAINEE:
trainee = who.trainee_id
rows = await repo.history(
db,
trainee_id=trainee,

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")