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

@ -41,6 +41,9 @@ typecheck: ## Проверить фронтенд компилятором
models: ## Скачать модели в backend/models/: эмбеддинги, GigaAM, Silero VAD; Silero TTS — вручную
cd backend && $(UV) run python scripts/models.py
users: ## Завести учётные записи ролей и напечатать пароли: make users force=1
cd backend && $(UV) run python scripts/users.py
seed: ## Залить сценарии из /scenarios в БД
cd backend && $(UV) run python scripts/seed.py
@ -71,4 +74,4 @@ pregen: ## Построить таблицу реплик звонящего д
demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт
./scripts/demo.sh
.PHONY: help dev down back front types test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo
.PHONY: help dev down back front types users test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo

168
backend/app/api/auth.py Normal file
View file

@ -0,0 +1,168 @@
"""Вход, роли и аудит.
ТЗ называет три роли поимённо — администратор, преподаватель, обучающийся —
и требует разграничения доступа, аудита действий и хранения журналов
безопасности (docs/spec/GAP.md#1-аутентификации-нет-вообще). До этой карточки
входа не было вовсе: экраны открывались ссылкой с номером занятия.
Решения, принятые заранее и не переоткрываемые (tasks/lct-23-auth-roles.md):
* пароль — `argon2`, не bcrypt;
* сессия — подписанная cookie через `SessionMiddleware`, не JWT и не таблица
сессий: состояние занятия и так живёт в памяти одного воркера, лишний поход
в базу на каждый запрос не нужен;
* на сокетах роль читается из cookie в момент рукопожатия — отдельного
протокола авторизации в канале нет.
"""
import logging
from uuid import UUID
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import APIRouter, HTTPException, Request, WebSocket
from pydantic import BaseModel
from sqlalchemy import select
from app.config import get_settings
from app.db.base import get_sessionmaker
from app.db.models import AuditLog, User
from app.domain.roles import Role
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
_hasher = PasswordHasher()
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password_hash: str, password: str) -> bool:
try:
return _hasher.verify(password_hash, password)
except VerifyMismatchError:
return False
except Exception: # noqa: BLE001 — битый хеш не должен пускать в систему
log.exception("проверка пароля не удалась")
return False
class Principal(BaseModel):
"""Кто действует. Хранится в cookie целиком: ходить в базу за ролью
на каждый запрос незачем, а меняется она правкой учётной записи."""
login: str
full_name: str
role: Role
service: str | None = None
trainee_id: UUID | None = None
@property
def is_staff(self) -> bool:
return self.role in (Role.ADMIN, Role.INSTRUCTOR)
class LoginIn(BaseModel):
login: str
password: str
def current(request: Request) -> Principal | None:
data = request.session.get("principal")
return Principal.model_validate(data) if data else None
def principal_of(websocket: WebSocket) -> Principal | None:
"""Роль на сокете — из той же cookie, что и на HTTP.
`WebSocket.session` доступен, потому что `SessionMiddleware` стоит до
роутера: значит роль известна до входа в цикл приёма сообщений.
"""
data = websocket.session.get("principal") if "session" in websocket.scope else None
return Principal.model_validate(data) if data else None
def require(request: Request, *roles: Role) -> Principal:
"""Принципал нужной роли или отказ. Единственная точка проверки на HTTP."""
who = current(request)
if who is None:
raise HTTPException(status_code=401, detail="not_authenticated")
if roles and who.role not in roles:
raise HTTPException(status_code=403, detail="forbidden")
return who
async def audit(
actor: str, role: str, action: str, object_id: str | None = None, detail: str = ""
) -> None:
"""Запись в журнал. Аудит не должен ронять действие: если база недоступна,
занятие продолжается, а пропуск виден в логе."""
try:
async with get_sessionmaker()() as db:
db.add(
AuditLog(
actor=actor, role=role, action=action, object_id=object_id, detail=detail[:2000]
)
)
await db.commit()
except Exception: # noqa: BLE001
log.exception("аудит: запись %s не удалась", action)
@router.post("/login")
async def login(payload: LoginIn, request: Request) -> dict:
async with get_sessionmaker()() as db:
user = await db.scalar(select(User).where(User.login == payload.login))
# Одинаковый ответ на неизвестный логин и неверный пароль: иначе форма
# входа превращается в список действующих учётных записей.
if user is None or not verify_password(user.password_hash, payload.password):
raise HTTPException(status_code=401, detail="bad_credentials")
if user.blocked:
raise HTTPException(status_code=403, detail="blocked")
who = Principal(
login=user.login,
full_name=user.full_name,
role=Role(user.role),
service=user.service,
trainee_id=user.trainee_id,
)
request.session["principal"] = who.model_dump(mode="json")
await audit(who.login, who.role.value, "login")
return who.model_dump(mode="json")
@router.post("/logout")
async def logout(request: Request) -> dict:
who = current(request)
request.session.clear()
if who:
await audit(who.login, who.role.value, "logout")
return {"ok": True}
@router.get("/me")
async def me(request: Request) -> dict:
who = current(request)
if who is None:
raise HTTPException(status_code=401, detail="not_authenticated")
return who.model_dump(mode="json")
@router.post("/dev-token")
async def dev_token(request: Request) -> dict:
"""Вход без пароля для наших же инструментов.
`make lesson` и тесты открывают сокеты напрямую и после включения ролей
сломались бы все разом. Точка живёт за флагом `dev_auth_bypass`, по
умолчанию выключенным: в рабочем стенде она отвечает 404, а не 401 —
выключенной функции не должно быть видно вовсе.
"""
if not get_settings().dev_auth_bypass:
raise HTTPException(status_code=404, detail="not_found")
who = Principal(login="dev", full_name="Разработка", role=Role.INSTRUCTOR)
request.session["principal"] = who.model_dump(mode="json")
return who.model_dump(mode="json")

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

View file

@ -29,6 +29,8 @@ from app.domain.events import (
from app.domain.events import BgStart
from app.scenarios import store
from app.session.finish import finish, release_score
from app.api.auth import principal_of
from app.domain.roles import Role
from app.session.hub import hub
from app.session.state import now_utc
from app.voice.models import get_voice_models
@ -198,10 +200,24 @@ async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
await ws.send_text(item.model_dump_json())
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/call/{session_id}")
async def call(ws: WebSocket, session_id: UUID) -> None:
await ws.accept()
# АРМ курсанта. Преподаватель допущен, чтобы показать приём вызова группе.
who = principal_of(ws)
if who is None or who.role not in (Role.TRAINEE, Role.INSTRUCTOR):
await _reject(ws, "Недостаточно прав для этого экрана")
return
state = hub.get(session_id)
if state is None:
await ws.send_text(

View file

@ -38,6 +38,8 @@ from app.dialog.persona import PersonaState
from app.dialog.runtime import get_embedder
from app.dialog.slots import SlotMachine
from app.scenarios import store
from app.api.auth import audit, principal_of
from app.domain.roles import Role
from app.session.hub import hub
from app.session.state import SessionState, now_utc
from app.voice.models import get_voice_models
@ -49,7 +51,7 @@ router = APIRouter()
_adapter = TypeAdapter(InstructorToServer)
async def _start(session_id: UUID, event) -> None:
async def _start(session_id: UUID, event, who=None) -> None:
scenario = store.get(event.scenario_id)
if scenario is None:
hub.to_observers(
@ -76,6 +78,7 @@ async def _start(session_id: UUID, event) -> None:
scenario=scenario.model_copy(deep=True),
required_fields=scenario.required_fields,
trainee_name=event.trainee,
trainee_id=getattr(event, "trainee_id", None),
attempt=attempt,
)
embedder = get_embedder()
@ -92,6 +95,13 @@ async def _start(session_id: UUID, event) -> None:
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
state.on_event("call.incoming")
hub.start_ticker(session_id)
if who is not None:
# Запуск занятия меняет чужой результат — значит попадает в журнал
# аудита (ТЗ, хранение не менее шести месяцев).
asyncio.create_task(
audit(who.login, who.role.value, "lesson.start", str(session_id),
f"{scenario.id}, режим {event.mode.value}, курсант {event.trainee}")
)
hub.to_trainee(
session_id,
@ -120,9 +130,23 @@ async def _stop(session_id: UUID) -> None:
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value)
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/control/{session_id}")
async def control(ws: WebSocket, session_id: UUID) -> None:
await ws.accept()
# Пульт преподавателя: управление занятием доступно только ему.
who = principal_of(ws)
if who is None or who.role not in (Role.INSTRUCTOR,):
await _reject(ws, "Недостаточно прав для этого экрана")
return
try:
while True:
payload = await ws.receive_json()
@ -137,7 +161,7 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
match event.type:
case "scenario.start":
await _start(session_id, event)
await _start(session_id, event, who)
case "session.stop":
await _stop(session_id)
case "instructor_note.add":

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(

View file

@ -17,6 +17,8 @@ from pydantic import TypeAdapter, ValidationError
from app.domain.events import ErrorEvent, ErrorKind, StationState, StationToServer
from app.domain.statuses import PRIMARY, ServiceStatus, StatusError
from app.api.auth import principal_of
from app.domain.roles import Role
from app.session.hub import hub
from app.session.state import now_utc
@ -78,10 +80,24 @@ async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
await ws.send_text(event.model_dump_json())
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/station/{session_id}")
async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
await ws.accept()
# За АРМ ДДС садится обучающийся, преподаватель смотрит и подменяет.
who = principal_of(ws)
if who is None or who.role not in (Role.INSTRUCTOR, Role.TRAINEE):
await _reject(ws, "Недостаточно прав для этого экрана")
return
state = hub.get(session_id)
if state is None:
await ws.send_text(

View file

@ -42,6 +42,13 @@ class Settings(BaseSettings):
)
judge_temperature: float = 0.0
# Вход и роли (lct-23). `session_secret` обязателен на рабочем стенде:
# значение по умолчанию годится только для разработки, и администратор
# видит в состоянии сервисов, что оно не заменено.
session_secret: str = "dev-secret-поменять-на-стенде"
#: Вход без пароля для `make lesson` и тестов. На стенде выключен.
dev_auth_bypass: bool = False
# Нормативы: переопределяют значения по умолчанию из domain/timers.py
timer_limits_ms: dict[TimerCode, int] = {}

View file

@ -0,0 +1,53 @@
"""вход и роли: users, audit_log
Revision ID: e6e2ab171dd7
Revises: ff5de7dfe4ce
Create Date: 2026-09-20 08:47:33.911250
"""
from alembic import op
import sqlalchemy as sa
revision = 'e6e2ab171dd7'
down_revision = 'ff5de7dfe4ce'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('audit_log',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('actor', sa.String(length=80), nullable=False),
sa.Column('role', sa.String(length=16), nullable=False),
sa.Column('action', sa.String(length=40), nullable=False),
sa.Column('object_id', sa.String(length=80), nullable=True),
sa.Column('detail', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_audit_at', 'audit_log', ['at'], unique=False)
op.create_table('users',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('login', sa.String(length=80), nullable=False),
sa.Column('password_hash', sa.String(length=200), nullable=False),
sa.Column('full_name', sa.String(length=120), nullable=False),
sa.Column('role', sa.String(length=16), nullable=False),
sa.Column('service', sa.String(length=120), nullable=True),
sa.Column('trainee_id', sa.Uuid(), nullable=True),
sa.Column('blocked', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['trainee_id'], ['trainees.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('login')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('users')
op.drop_index('ix_audit_at', table_name='audit_log')
op.drop_table('audit_log')
# ### end Alembic commands ###

View file

@ -195,6 +195,49 @@ class Score(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class User(Base):
"""Учётная запись. Три роли из ТЗ: администратор, преподаватель, обучающийся.
`service` заполняется только у обучающегося и означает ДДС, в которой он
работает: ТЗ требует, чтобы «в ленту попадали только профильные события»,
а профильность определяется списком оповещения по ЕКП, больше ничем
(docs/spec/DATASET.md). У преподавателя и администратора поле пустое.
"""
__tablename__ = "users"
id: Mapped[UUID] = _uuid_pk()
login: Mapped[str] = mapped_column(String(80), unique=True)
password_hash: Mapped[str] = mapped_column(String(200))
full_name: Mapped[str] = mapped_column(String(120))
role: Mapped[str] = mapped_column(String(16))
service: Mapped[str | None] = mapped_column(String(120))
trainee_id: Mapped[UUID | None] = mapped_column(ForeignKey("trainees.id", ondelete="SET NULL"))
blocked: Mapped[bool] = mapped_column(default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AuditLog(Base):
"""Журнал действий. ТЗ требует хранения не менее шести месяцев, поэтому
записи не удаляются вместе с сессией: `object_id` — строка, а не ссылка.
Пишется то, что меняет чужой результат или состав системы: запуск занятия,
оценка, коррекция оценки, правка сценария, вход и выход.
"""
__tablename__ = "audit_log"
id: Mapped[UUID] = _uuid_pk()
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
actor: Mapped[str] = mapped_column(String(80))
role: Mapped[str] = mapped_column(String(16))
action: Mapped[str] = mapped_column(String(40))
object_id: Mapped[str | None] = mapped_column(String(80))
detail: Mapped[str] = mapped_column(Text, default="")
__table_args__ = (Index("ix_audit_at", "at"),)
class LlmCache(Base):
"""Кэш ответов LLM по хешу контекста. Работает и онлайн — экономия
на повторах, — и как накопитель материала для офлайн-дерева."""

View file

@ -19,6 +19,7 @@ from pydantic import BaseModel, Field
from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
from app.domain.kio import KIO, Coords
from app.domain.roles import Role
from app.domain.statuses import ServiceStatus, StationSnapshot
from app.domain.taxonomy import Finding
from app.domain.timers import TimerSnapshot
@ -79,6 +80,9 @@ class ErrorKind(StrEnum):
DIRECTIVE_NEEDS_NETWORK = "directive_needs_network"
SCENARIO_INVALID = "scenario_invalid"
UNSUPPORTED_EVENT = "unsupported_event"
#: Канал открыт не тем, кому он предназначен (lct-23). Фронт по этому коду
#: отправляет на вход, а не показывает «занятие не найдено».
FORBIDDEN = "forbidden"
INTERNAL = "internal"
@ -585,9 +589,24 @@ class SessionReport(BaseModel):
override_comment: str | None = None
class Principal(BaseModel):
"""Кто вошёл. В событиях не участвует, но фронт разводит по роли экраны,
и список ролей должен приезжать из домена, а не переписываться руками
(lct-23). Полная модель — в `app/api/auth.py`.
"""
login: str
full_name: str
role: Role
service: str | None = None
trainee_id: UUID | None = None
class EventCatalog(BaseModel):
"""Единственное назначение — собрать все союзы в одну JSON Schema для make types."""
principal: Principal
server_to_trainee: ServerToTrainee
trainee_to_server: TraineeToServer
server_to_observer: ServerToObserver

View file

@ -0,0 +1,29 @@
"""Роли пользователей. Три названы в ТЗ поимённо, четвёртой нет.
Роль лежит в домене, а не в модуле входа: по ней разводятся и экраны фронта,
и каналы сокетов, и она уезжает в TypeScript через `make types`.
"""
from enum import StrEnum
class Role(StrEnum):
ADMIN = "admin"
INSTRUCTOR = "instructor"
TRAINEE = "trainee"
ROLE_LABELS: dict[Role, str] = {
Role.ADMIN: "Администратор системы",
Role.INSTRUCTOR: "Преподаватель",
Role.TRAINEE: "Обучающийся",
}
#: Что кому доступно. Ограничения из ТЗ: преподаватель не меняет системные
#: настройки, администратор не вмешивается в оценки, обучающийся не видит
#: чужих результатов.
SCREENS: dict[Role, tuple[str, ...]] = {
Role.ADMIN: ("/admin", "/profile"),
Role.INSTRUCTOR: ("/instructor", "/wall", "/profile", "/dds"),
Role.TRAINEE: ("/trainee", "/profile"),
}

View file

@ -4,9 +4,11 @@ import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware
from pathlib import Path
from app.api import auth
from app.api.http import ekp as ekp_api
from app.api.http import scenarios as scenarios_api
from app.api.http import sessions
@ -61,6 +63,16 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Учебный симулятор занятия для системы 112", lifespan=lifespan)
# Сессия ставится до роутеров: роль должна быть известна и на HTTP, и в момент
# рукопожатия сокета, иначе проверять её в канале будет нечем (app/api/auth.py).
app.add_middleware(
SessionMiddleware,
secret_key=get_settings().session_secret,
session_cookie="lct_session",
https_only=False, # учебный класс живёт по http во внутренней сети
max_age=12 * 60 * 60, # смена занятий, не месяц
)
app.include_router(auth.router)
app.include_router(sessions.router)
app.include_router(scenarios_api.router)
app.include_router(ekp_api.router)

View file

@ -50,6 +50,9 @@ class SessionState:
mode: SessionMode
required_fields: list[str] = field(default_factory=list)
trainee_name: str | None = None
#: Чьё это занятие. Проставляется при запуске, когда курсант известен
#: по учётной записи: по нему разбор закрывается от чужих (lct-23).
trainee_id: UUID | None = None
attempt: int = 1
kio: KIO = field(default_factory=KIO)

View file

@ -22,6 +22,10 @@ dependencies = [
# Числа словами перед синтезом: Silero молча выбрасывает цифры, и звонящий
# произносит «улица Ленина, дом… квартира…» без номеров.
"num2words>=0.5.13",
# Вход и роли (lct-23): argon2 — современная рекомендация по хешированию
# паролей, itsdangerous нужен SessionMiddleware из starlette.
"argon2-cffi>=23.1",
"itsdangerous>=2.1",
]
[project.optional-dependencies]

View file

@ -11,6 +11,7 @@
import asyncio
import json
import sys
import urllib.request
import uuid
import websockets
@ -19,9 +20,35 @@ BACKEND = "ws://localhost:8000"
FRONTEND = "http://localhost:5173"
def dev_cookie() -> str:
"""Вход для служебного скрипта.
Сокеты закрыты ролями (lct-23), а `make lesson` — наш инструмент, а не
пользователь. Точка `dev-token` живёт за флагом `DEV_AUTH_BYPASS` и на
рабочем стенде отвечает 404 — тогда скрипт честно скажет, что занятие
запускается с пульта преподавателя.
"""
request = urllib.request.Request(
f"{BACKEND.replace('ws://', 'http://')}/api/auth/dev-token", method="POST"
)
try:
with urllib.request.urlopen(request, timeout=5) as response:
return response.headers.get("set-cookie", "").split(";")[0]
except urllib.error.HTTPError as exc:
if exc.code == 404:
raise SystemExit(
"вход выключен: на стенде занятие запускается с пульта преподавателя.\n"
"Для разработки поставь DEV_AUTH_BYPASS=true в backend/.env"
) from exc
raise
async def main(scenario_id: str, mode: str) -> None:
session_id = str(uuid.uuid4())
async with websockets.connect(f"{BACKEND}/ws/control/{session_id}") as control:
headers = {"Cookie": dev_cookie()}
async with websockets.connect(
f"{BACKEND}/ws/control/{session_id}", additional_headers=headers
) as control:
await control.send(json.dumps({
"type": "scenario.start",
"scenario_id": scenario_id,

78
backend/scripts/users.py Normal file
View file

@ -0,0 +1,78 @@
"""make users: завести учётные записи для занятия.
Без входа система не работает (lct-23), а заводить первого администратора
руками в базе — плохой первый шаг для того, кто разворачивает стенд.
Скрипт создаёт по одной записи на роль и печатает пароли один раз.
Пароли случайные: зашитый в репозиторий «admin/admin» переживёт сдачу
и уедет в реальный класс.
make users создать недостающие
make users force=1 пересоздать пароли существующих
"""
import asyncio
import os
import secrets
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from sqlalchemy import select # noqa: E402
from app.api.auth import hash_password # noqa: E402
from app.db.base import get_sessionmaker # noqa: E402
from app.db.models import Trainee, User # noqa: E402
from app.domain.roles import Role # noqa: E402
PEOPLE = [
("admin", "Администратор стенда", Role.ADMIN, None),
("teacher", "Преподаватель", Role.INSTRUCTOR, None),
("trainee", "Курсант", Role.TRAINEE, "ДДС района"),
]
async def main() -> int:
force = bool(os.environ.get("force"))
created: list[tuple[str, str]] = []
async with get_sessionmaker()() as db:
for login, full_name, role, service in PEOPLE:
user = await db.scalar(select(User).where(User.login == login))
if user is not None and not force:
print(f" {login}: уже есть")
continue
password = secrets.token_urlsafe(9)
if user is None:
user = User(login=login, full_name=full_name, role=role.value, service=service)
db.add(user)
user.password_hash = hash_password(password)
user.blocked = False
# У обучающегося должна быть карточка курсанта: по ней собираются
# профиль и история, и по ней же разбор закрывается от чужих.
if role is Role.TRAINEE and user.trainee_id is None:
trainee = Trainee(name=full_name)
db.add(trainee)
await db.flush()
user.trainee_id = trainee.id
created.append((login, password))
await db.commit()
if not created:
print("все учётные записи на месте (пересоздать: make users force=1)")
return 0
print("\nЗаписано. Пароли показываются один раз:\n")
for login, password in created:
print(f" {login:10} {password}")
print("\nСменить пароль можно только пересозданием: экран смены пароля — lct-24.")
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

31
backend/tests/conftest.py Normal file
View file

@ -0,0 +1,31 @@
"""Общая настройка тестов.
Голосовой контур выключен: он грузит модели около десяти секунд, а проверяется
отдельно в `test_voice_pipeline.py`. Вход включён как на стенде, но тесты
логинятся через `dev-token` — так же, как `make lesson` (tasks/lct-23-auth-roles.md).
"""
import os
os.environ.setdefault("DEV_AUTH_BYPASS", "true")
import pytest # noqa: E402
from app.config import get_settings # noqa: E402
@pytest.fixture(autouse=True, scope="session")
def _dev_auth():
"""Флаг обхода читается один раз при создании настроек."""
get_settings.cache_clear()
settings = get_settings()
assert settings.dev_auth_bypass, "тесты должны ходить через dev-token"
yield
@pytest.fixture
def as_instructor(client):
"""Клиент, вошедший преподавателем. Cookie живёт в самом TestClient."""
response = client.post("/api/auth/dev-token")
assert response.status_code == 200, response.text
return client

128
backend/tests/test_auth.py Normal file
View file

@ -0,0 +1,128 @@
"""Вход, роли и аудит.
Пункты приёмки lct-23: курсант не открывает пульт даже по прямой ссылке,
администратор не правит оценки, чужой разбор закрыт, пароли не хранятся
в открытом виде.
Тесты, которым нужна база, пропускаются, когда Postgres не поднят: остальные
проверяют разграничение, для которого база не нужна.
"""
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.api.auth import hash_password, verify_password
from app.domain.roles import Role
from app.main import app
from app.session.hub import hub
@pytest.fixture
def client():
with TestClient(app) as test_client:
hub.journal = None
yield test_client
# ── пароли ──
def test_password_is_hashed_not_stored():
stored = hash_password("правильный-пароль")
assert "правильный-пароль" not in stored
assert stored.startswith("$argon2")
def test_password_verifies():
stored = hash_password("правильный-пароль")
assert verify_password(stored, "правильный-пароль")
assert not verify_password(stored, "другой")
def test_broken_hash_does_not_let_anyone_in():
"""Битая запись в базе не должна открывать вход."""
assert not verify_password("не хеш вовсе", "что угодно")
# ── вход ──
def test_unknown_login_and_wrong_password_look_the_same(client):
"""Иначе форма входа превращается в список действующих учётных записей."""
first = client.post("/api/auth/login", json={"login": "нет-такого", "password": "x"})
assert first.status_code == 401
assert first.json()["detail"] == "bad_credentials"
def test_me_requires_authentication(client):
assert client.get("/api/auth/me").status_code == 401
def test_dev_token_gives_an_instructor(client):
response = client.post("/api/auth/dev-token")
assert response.status_code == 200
assert response.json()["role"] == "instructor"
assert client.get("/api/auth/me").json()["role"] == "instructor"
def test_logout_clears_the_session(client):
client.post("/api/auth/dev-token")
client.post("/api/auth/logout")
assert client.get("/api/auth/me").status_code == 401
# ── разграничение ──
def test_anonymous_cannot_open_any_socket(client):
"""Номер занятия перестал быть пропуском: раньше по ссылке пускало знание
адреса, теперь — роль."""
session_id = uuid4()
for path in (f"/ws/control/{session_id}", f"/ws/call/{session_id}",
f"/ws/observe/{session_id}", f"/ws/station/{session_id}"):
with client.websocket_connect(path) as socket:
message = socket.receive_json()
assert message["type"] == "error"
assert message["code"] == "forbidden", path
def test_anonymous_cannot_read_history(client):
assert client.get("/api/sessions").status_code == 401
def test_anonymous_cannot_read_trainees(client):
assert client.get("/api/trainees").status_code == 401
def test_classifier_is_open_to_everyone(client):
"""Справочник ЕКП прятать не от кого: оператор видит тот же список
на боевом АРМ."""
assert client.get("/api/ekp/groups").status_code == 200
def test_health_stays_open(client):
"""Экран «стенд прогревается» показывается до входа."""
assert client.get("/api/health").status_code == 200
def test_instructor_can_open_control(client):
"""На `control` сервер не шлёт ничего — это канал только на запись
(docs/arch/CONTRACT.md). Признак того, что он открыт, — поднявшееся
занятие, а не ответное сообщение."""
import time
client.post("/api/auth/dev-token")
session_id = uuid4()
with client.websocket_connect(f"/ws/control/{session_id}") as socket:
socket.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Иванов", "mode": "training"})
deadline = time.monotonic() + 3
while time.monotonic() < deadline and hub.get(session_id) is None:
time.sleep(0.02)
assert hub.get(session_id) is not None, "занятие не поднялось: канал отвергнут"
def test_roles_are_three_and_named_in_the_spec():
assert {role.value for role in Role} == {"admin", "instructor", "trainee"}

View file

@ -76,7 +76,10 @@ def test_session_modes_are_the_three_from_docs():
def test_event_catalog_covers_every_channel():
"""`principal` каналом не является: он в каталоге ради того, чтобы список
ролей приезжал на фронт из домена, а не переписывался руками (lct-23)."""
assert set(EventCatalog.model_fields) == {
"principal",
"server_to_trainee",
"trainee_to_server",
"server_to_observer",

View file

@ -27,6 +27,9 @@ def client():
except OSError as exc:
pytest.skip(f"Postgres недоступен ({exc}) — подними `make dev`")
with TestClient(app) as test_client:
# Сокеты закрыты ролями (lct-23): тесты входят так же,
# как `make lesson`, — через dev-token за флагом.
test_client.post("/api/auth/dev-token")
yield test_client

View file

@ -13,6 +13,9 @@ from app.session.hub import hub
@pytest.fixture
def client():
with TestClient(app) as test_client:
# Сокеты закрыты ролями (lct-23): тесты входят так же,
# как `make lesson`, — через dev-token за флагом.
test_client.post("/api/auth/dev-token")
hub.journal = None
yield test_client

View file

@ -19,6 +19,9 @@ from app.session.hub import hub
@pytest.fixture
def client():
with TestClient(app) as test_client:
# Сокеты закрыты ролями (lct-23): тесты входят так же,
# как `make lesson`, — через dev-token за флагом.
test_client.post("/api/auth/dev-token")
hub.journal = None # тесты не пишут в БД: проверяется поведение каналов
yield test_client

83
backend/uv.lock generated
View file

@ -51,6 +51,43 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "argon2-cffi"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi-bindings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
]
[[package]]
name = "argon2-cffi-bindings"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" },
{ url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" },
{ url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" },
{ url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" },
{ url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" },
{ url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" },
{ url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" },
{ url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" },
{ url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" },
{ url = "https://files.pythonhosted.org/packages/a0/b9/97f0370f99611b14efd384918613dd5cbda75f28d9bb1b677aacfeaa17df/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:19b562b1de4b9052ef1214a2821c44b6e6f22945daa102c32ae4eff929d8b6d8", size = 23055, upload-time = "2026-08-20T07:33:19.716Z" },
{ url = "https://files.pythonhosted.org/packages/ae/70/7eb3fe7bf00103cbbb569c51aef150661f22b734a782673a600ff0f52309/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d525938467d52c923a890153c99087c9d5a937d1f6b585dbdba34ec82e397a", size = 24869, upload-time = "2026-08-20T07:33:20.671Z" },
{ url = "https://files.pythonhosted.org/packages/5b/4b/9d5919c6cb1f15df7406af0f99b048bd93936f112e3e8f4c8077bc2a9110/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b0bcac4d490a237e18cf91f57352920c29f77f2fa39efd0813fb81298bf17ba", size = 24216, upload-time = "2026-08-20T07:33:21.653Z" },
{ url = "https://files.pythonhosted.org/packages/a3/34/32109943bace7729233cc4ee78530baa306d8cc3c6501a64ba8cb3b58129/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0cc40f7b4050bb93eb67de95d2d759322fc7ce4930b9d645581ecf4913ec651e", size = 23584, upload-time = "2026-08-20T07:33:22.613Z" },
]
[[package]]
name = "asyncpg"
version = "0.31.0"
@ -76,6 +113,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
{ url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
{ url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
{ url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
{ url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
{ url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
{ url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
{ url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
{ url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
{ url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
{ url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
{ url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
]
[[package]]
name = "click"
version = "8.5.0"
@ -266,6 +327,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
@ -284,9 +354,11 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "argon2-cffi" },
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "itsdangerous" },
{ name = "num2words" },
{ name = "numpy" },
{ name = "onnxruntime" },
@ -314,9 +386,11 @@ voice = [
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.13" },
{ name = "argon2-cffi", specifier = ">=23.1" },
{ name = "asyncpg", specifier = ">=0.29" },
{ name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "itsdangerous", specifier = ">=2.1" },
{ name = "num2words", specifier = ">=0.5.13" },
{ name = "numpy", specifier = ">=1.26" },
{ name = "onnx-asr", marker = "extra == 'voice'", specifier = ">=0.6" },
@ -485,6 +559,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/ca/c47f91d3cab175b01fd8c4f0d80fdf8613be876cc616e66ad281a59c5ddf/protobuf-7.36.1-py3-none-any.whl", hash = "sha256:7d951e46b3f963d6c264c367c437921de9d5aedd9c3f9612b9077736b4e3ad5c", size = 179813, upload-time = "2026-08-31T22:40:03.54Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.13.5"

View file

@ -5,14 +5,22 @@ import { Instructor } from "@/pages/instructor/Instructor";
import { Dds } from "@/pages/dds/Dds";
import { Profile } from "@/pages/profile/Profile";
import { Wall } from "@/pages/wall/Wall";
import { RequireAuth } from "@/features/auth/Login";
// Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md).
// session_id живёт в URL: /instructor?session=..., монитор открывают ссылкой.
// Каждый экран за проверкой роли: курсант не открывает пульт даже по прямой
// ссылке. Сервер проверяет то же самое на сокетах — здесь только чтобы человек
// увидел понятное объяснение вместо пустого экрана (lct-23).
const guarded = (screen: string, element: JSX.Element) => (
<RequireAuth screen={screen}>{element}</RequireAuth>
);
export const router = createBrowserRouter([
{ path: "/", element: <Navigate to="/trainee" replace /> },
{ path: "/trainee", element: <Call /> },
{ path: "/instructor", element: <Instructor /> },
{ path: "/wall", element: <Wall /> },
{ path: "/profile", element: <Profile /> },
{ path: "/dds", element: <Dds /> },
{ path: "/trainee", element: guarded("/trainee", <Call />) },
{ path: "/instructor", element: guarded("/instructor", <Instructor />) },
{ path: "/wall", element: guarded("/wall", <Wall />) },
{ path: "/profile", element: guarded("/profile", <Profile />) },
{ path: "/dds", element: guarded("/dds", <Dds />) },
]);

View file

@ -0,0 +1,78 @@
// Экран входа. Ставится перед всеми остальными: номер занятия перестал быть
// пропуском, по ссылке пускает роль, а не знание адреса (lct-23).
import { useState, type ReactNode } from "react";
import { SCREENS, useLogin, useLogout, useMe } from "./useAuth";
export function RequireAuth({ screen, children }: { screen: string; children: ReactNode }) {
const me = useMe();
const logout = useLogout();
if (me.isLoading) {
return <main className="page"><p>Проверяем доступ…</p></main>;
}
if (!me.data) return <Login />;
const allowed = SCREENS[me.data.role] ?? [];
if (!allowed.includes(screen)) {
return (
<main className="page">
<h1>Экран недоступен</h1>
<p className="warn">
Вы вошли как {me.data.full_name} ({me.data.role}). Этот экран открыт другой роли.
</p>
<p>Доступно: {allowed.join(", ") || "ничего"}</p>
<p><button type="button" onClick={() => logout.mutate()}>Выйти</button></p>
</main>
);
}
return <>{children}</>;
}
function Login() {
const login = useLogin();
const [name, setName] = useState("");
const [password, setPassword] = useState("");
return (
<main className="page">
<h1>Учебный симулятор системы-112</h1>
<form
onSubmit={(event) => {
event.preventDefault();
login.mutate({ login: name, password });
}}
>
<table className="grid">
<tbody>
<tr>
<th>Логин</th>
<td>
<input value={name} autoFocus onChange={(event) => setName(event.target.value)} />
</td>
</tr>
<tr>
<th>Пароль</th>
<td>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</td>
</tr>
</tbody>
</table>
<p>
<button type="submit" disabled={!name || !password || login.isPending}>
Войти
</button>
</p>
</form>
{/* Причину отказа не уточняем: «нет такого логина» — это список
действующих учётных записей для того, кто подбирает. */}
{login.isError && <p className="warn">Логин или пароль неверны</p>}
</main>
);
}

View file

@ -0,0 +1,64 @@
// Кто сейчас за экраном. Роль приходит с сервера, а не хранится на фронте:
// подписанная cookie — источник истины, здесь только её отражение (lct-23).
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Role } from "@/shared/types/generated";
export interface Principal {
login: string;
full_name: string;
role: Role;
service: string | null;
trainee_id: string | null;
}
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
if (!response.ok) throw new Error(String(response.status));
return response.json() as Promise<T>;
}
export function useMe() {
return useQuery({
queryKey: ["me"],
// Отсутствие входа — не ошибка, а состояние: возвращаем null, чтобы
// экран показал форму, а не «что-то пошло не так».
queryFn: async () => {
try {
return await call<Principal>("/api/auth/me");
} catch {
return null;
}
},
retry: false,
});
}
export function useLogin() {
const cache = useQueryClient();
return useMutation({
mutationFn: (body: { login: string; password: string }) =>
call<Principal>("/api/auth/login", { method: "POST", body: JSON.stringify(body) }),
onSuccess: (who) => cache.setQueryData(["me"], who),
});
}
export function useLogout() {
const cache = useQueryClient();
return useMutation({
mutationFn: () => call<{ ok: boolean }>("/api/auth/logout", { method: "POST" }),
onSuccess: () => cache.setQueryData(["me"], null),
});
}
/** Какие экраны открыты роли. Повторяет domain/roles.py: список короткий
* и меняется редко, а держать его на сервере ради двух строк — лишний запрос. */
export const SCREENS: Record<Role, string[]> = {
admin: ["/admin", "/profile"],
instructor: ["/instructor", "/wall", "/profile", "/dds"],
trainee: ["/trainee", "/profile"],
};

View file

@ -150,7 +150,7 @@ export interface ErrorEvent {
}
/** Коды канала `error`. Фронт разбирает код, а не текст сообщения: текст — для человека, код — для поведения интерфейса. */
export type ErrorKind = "session_not_found" | "call_not_started" | "hint_denied_in_exam" | "models_warming_up" | "directive_needs_network" | "scenario_invalid" | "unsupported_event" | "internal";
export type ErrorKind = "session_not_found" | "call_not_started" | "hint_denied_in_exam" | "models_warming_up" | "directive_needs_network" | "scenario_invalid" | "unsupported_event" | "forbidden" | "internal";
/** Отметка в разборе. `fact` и `norm` — то самое обоснование. */
export interface Finding {
@ -314,6 +314,15 @@ export interface PoliceDetails {
vehicle?: string | null;
}
/** Кто вошёл. В событиях не участвует, но фронт разводит по роли экраны, и список ролей должен приезжать из домена, а не переписываться руками (lct-23). Полная модель — в `app/api/auth.py`. */
export interface Principal {
login: string;
full_name: string;
role: Role;
service?: string | null;
trainee_id?: string | null;
}
export interface ReferencePlay {
type: "reference.play";
}
@ -326,6 +335,8 @@ export interface ReferenceStarted {
export type ResponseStatus = "registered" | "transferred" | "in_progress" | "closed";
export type Role = "admin" | "instructor" | "trainee";
export interface ScenarioPublish {
type: "scenario.publish";
scenario_id: string;

View file

@ -28,8 +28,9 @@ docker compose up -d postgres
( cd backend && uv run alembic upgrade head >/dev/null )
echo " миграции накатаны"
say "3/5 Сценарии"
say "3/5 Сценарии и учётные записи"
make seed
make users
say "4/5 Бэкенд с голосом"
# Контейнерные бэкенд и фронт гасим: порты те же, а голос работает нативно.
@ -53,6 +54,7 @@ FRONTEND=$!
say "Стенд поднят"
cat <<'LINKS'
вход: http://localhost:5173/ (пароли печатает make users)
пульт преподавателя: http://localhost:5173/instructor
профили курсантов: http://localhost:5173/profile
ссылки на АРМ курсанта, монитор и ДДС печатает сам пульт