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:
parent
4c2bbfd021
commit
d55c8bc8cd
30 changed files with 984 additions and 21 deletions
|
|
@ -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
78
backend/scripts/users.py
Normal 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()))
|
||||
Loading…
Reference in a new issue