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
168
backend/app/api/auth.py
Normal file
168
backend/app/api/auth.py
Normal 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")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue