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

@ -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()))