"""Общие зависимости FastAPI: авторизация, актор действия и сессия БД."""
import hashlib
from typing import Annotated, cast
from fastapi import Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.actor import VIA_API, VIA_UI, Actor, actor_from_request, agent_actor, declares_user
from app.db import get_db
from app.models import McpToken, User
DbDep = Annotated[Session, Depends(get_db)]
def user_from_bearer(request: Request, db: Session) -> dict[str, str] | None:
"""Fallback-аутентификация API-клиентов без сессии (расширение, агенты):
заголовок `Authorization: Bearer <MCP-токен>` → user_id владельца.
Токен в БД хранится только как sha256-хэш (как в McpAuthMiddleware для /mcp).
Рядом с user_id кладём актора (ТЗ 3.20): по токену действие считается агентским,
пока клиент не объявил `X-Actor: user` — см. actor_of.
"""
auth = request.headers.get("authorization", "")
token = auth.removeprefix("Bearer ").strip()
if not token:
return None
token_hash = hashlib.sha256(token.encode()).hexdigest()
# OUTER: строки users может не быть (тесты, «бесхозные» пользователи) — тогда
# подпись владельца пуста, но токен обязан работать.
row = db.execute(
select(McpToken.id, McpToken.label, McpToken.user_id, User.name)
.select_from(McpToken)
.outerjoin(User, User.id == McpToken.user_id)
.where(McpToken.token_hash == token_hash)
).first()
if row is None:
return None
request.state.gntodo_actor = agent_actor(row.label or "", row.id, row.name or "", VIA_API)
# Минимальный пользователь: API/сервисы читают только user_id (ТЗ 1.2).
return {"user_id": row.user_id}
def require_user(request: Request, db: DbDep) -> dict[str, str]:
"""Защита API: сессия SSO или Bearer MCP-токен (расширение, ИИ-агенты)."""
user = request.session.get("user")
if user:
# Сессия SSO — руки владельца (ТЗ 3.20)
request.state.gntodo_actor = Actor(name=str(user.get("name") or ""), via=VIA_UI)
return cast(dict[str, str], user)
bearer_user = user_from_bearer(request, db)
if not bearer_user:
raise HTTPException(status_code=401, detail="Not authenticated")
return bearer_user
UserDep = Annotated[dict[str, str], Depends(require_user)]
def actor_of(request: Request) -> Actor:
"""Актор действия этого запроса (ТЗ 3.20).
Заголовок `X-Actor: user` (REST) объявляет действие владельца: актор станет
`user` с его именем, но канал (`api`) сохранится — в журнале видно, что
действие заявлено владельцем и пришло от клиента с токеном.
"""
actor = actor_from_request(request)
if actor.is_agent and declares_user(request.headers):
return actor.declaring_user()
return actor
def require_actor(request: Request, _user: UserDep) -> Actor:
"""Актор запроса — после require_user: тот уже заполнил request.state."""
return actor_of(request)
ActorDep = Annotated[Actor, Depends(require_actor)]
def require_user_id(user: UserDep) -> str:
"""user_id текущего пользователя — ключ изоляции всех данных (ТЗ 1.2).
Один слой чтения: require_user уже проверил 401.
"""
return str(user["user_id"])
UserIdDep = Annotated[str, Depends(require_user_id)]