"""API-ключи источников для Ingestion (Bearer syn_...).
Ключ — удостоверение источника (docs/05-ingestion-api.md): событие обязано
нести source, совпадающий с ключом; правила маршрутизации матчают имя
источника, а не токен, поэтому ротация ключа на правила не влияет.
"""
import hashlib
import secrets
from datetime import UTC, datetime
from fastapi import HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import ApiKey, Source
MISSING_MSG = "Требуется API-ключ: Authorization: Bearer syn_..."
def generate_token() -> str:
"""Токен показывается один раз: `syn_` + 64 hex (32 байта энтропии)."""
return f"syn_{secrets.token_hex(32)}"
def token_hash_of(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def resolve_event_source(db: Session, request: Request) -> tuple[ApiKey, Source]:
"""Bearer-ключ -> (ApiKey, Source) в сессии запроса.
last_used_at фиксируется транзакцией запроса (коммитит роут).
401, если ключа нет/битый/отозван.
"""
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=MISSING_MSG,
headers={"WWW-Authenticate": "Bearer"},
)
digest = token_hash_of(auth[len("Bearer "):].strip())
row = db.execute(
select(ApiKey).where(ApiKey.token_hash == digest, ApiKey.revoked_at.is_(None))
).scalar_one_or_none()
if row is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API-ключ неизвестен или отозван",
headers={"WWW-Authenticate": "Bearer"},
)
source = db.get(Source, row.source_id)
if source is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Источник ключа удалён"
)
if source.deleted_at is not None:
# архив, не удаление: ключ жив, но события не принимает — админ
# восстановит источник (restore) или выдаст ключ новому
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Источник ключа в архиве — события не принимаются",
)
return row, source