Newer
Older
gnexus-tasks / backend / app / api / mcp_tokens.py
"""API per-user MCP-токенов (ТЗ 3.10): генерация и отзыв токенов для агентов.

Токен хранится только как sha256-хэш; plaintext показывается один раз при
генерации. Агент с токеном действует от имени владельца (см. McpAuthMiddleware
в main.py и _mcp_user в mcp_server.py).
"""

import hashlib
import secrets
from datetime import UTC, datetime
from typing import Any, cast

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from sqlalchemy import select

from app.dependencies import DbDep, UserIdDep
from app.models import McpToken

router = APIRouter(prefix="/api/mcp-tokens", tags=["mcp-tokens"])

TOKEN_PREFIX = "gnt_"


class McpTokenCreate(BaseModel):
    label: str = ""


class McpTokenOut(BaseModel):
    id: int
    label: str
    created_at: datetime


class McpTokenCreated(McpTokenOut):
    token: str  # plaintext — возвращается только один раз


def _now() -> datetime:
    return datetime.now(UTC)


def hash_token(token: str) -> str:
    return hashlib.sha256(token.encode()).hexdigest()


def _list_out(db: Any, user_id: str) -> list[McpTokenOut]:
    rows = db.scalars(
        select(McpToken).where(McpToken.user_id == user_id).order_by(McpToken.id)
    ).all()
    return [
        McpTokenOut(id=t.id, label=t.label, created_at=t.created_at.astimezone(UTC)) for t in rows
    ]


@router.get("", response_model=list[McpTokenOut])
async def list_tokens(db: DbDep, user_id: UserIdDep) -> list[McpTokenOut]:
    """Список токенов пользователя (без секретов)."""
    return _list_out(db, user_id)


@router.post("", response_model=McpTokenCreated)
async def create_token(schema: McpTokenCreate, db: DbDep, user_id: UserIdDep) -> Any:
    """Сгенерировать токен: plaintext в ответе ровно один раз, в БД — только хэш."""
    plaintext = TOKEN_PREFIX + secrets.token_urlsafe(32)
    row = McpToken(
        user_id=user_id,
        token_hash=hash_token(plaintext),
        label=schema.label[:100],
    )
    db.add(row)
    db.flush()
    return McpTokenCreated(
        id=row.id,
        label=row.label,
        created_at=row.created_at.astimezone(UTC),
        token=plaintext,
    )


@router.delete("/{token_id}")
async def delete_token(token_id: int, db: DbDep, user_id: UserIdDep) -> dict[str, bool]:
    row = cast(
        McpToken | None,
        db.scalar(select(McpToken).where(McpToken.id == token_id, McpToken.user_id == user_id)),
    )
    if row is None:
        raise HTTPException(status_code=404, detail="Token not found")
    db.delete(row)
    db.commit()
    return {"ok": True}