Newer
Older
tgclient-mcp / backend / app / api / mcp_tokens.py
"""Персональные MCP-ключи (канон handbook 10-platform/mcp.md, референс
gnexus-synapse): выдача, списки, ревок.

Выпуск — только из cookie-сессии: у Bearer-админ-токена нет личности, которой
можно выдать ключ, а ключ — персональные креды. Персональный mcp_* на /mcp
принимает гард require_mcp (app/security.py), статический
TGCLIENT_ADMIN_TOKEN остаётся супер-токеном. Plaintext существует ровно один
раз — в ответе 201 создания; в БД только sha256-хэш и хвост-хинт.
"""

import time

from pydantic import BaseModel, Field

from fastapi import APIRouter, Depends, HTTPException, Request

from app.auth import SESSION_COOKIE, get_session, get_user_row  # noqa: F401 (SESSION_COOKIE: /me-паттерн)
from app.db import get_db
from app.security import (
    generate_mcp_token,
    hash_key,
    mcp_token_hint,
    now_iso,
    require_admin,
    require_session,
)

ACTIVE_LIMIT = 10

router = APIRouter(prefix="/api/v1")

# Простейший rate-limit на выдачу (канон mcp.md: чувствительные операции):
# окно в памяти процесса — после рестарта окно чистое, для защиты от спама хватает.
_CREATE_WINDOW = 600.0
_CREATE_MAX = 5
_create_log: dict[str, list[float]] = {}


class TokenCreate(BaseModel):
    name: str = Field(default="", max_length=120)


def _row_out(row) -> dict:
    return {
        "id": row["id"],
        "name": row["name"],
        "token_hint": row["token_hint"],
        "system_role": row["system_role"],
        "user_email": row["user_email"],
        "created_at": row["created_at"],
        "last_used_at": row["last_used_at"],
        "revoked_at": row["revoked_at"],
    }


async def _issue(session: dict, name: str) -> dict:
    user_id = session["user_id"]
    db = get_db()
    cursor = await db.execute(
        "SELECT COUNT(*) AS n FROM mcp_tokens WHERE user_id = ? AND revoked_at IS NULL",
        (user_id,),
    )
    active = (await cursor.fetchone())["n"]
    if active >= ACTIVE_LIMIT:
        raise HTTPException(
            status_code=409,
            detail=f"too many active tokens ({active}/{ACTIVE_LIMIT}): revoke one first",
        )
    # снейпшот роли — из users (пишется при логине); свежие данные важнее
    user_row = await get_user_row(user_id)
    role = (user_row["system_role"] if user_row and user_row["system_role"] else "") or "user"
    token = generate_mcp_token()
    now = now_iso()
    cursor = await db.execute(
        "INSERT INTO mcp_tokens (user_id, name, user_email, system_role, token_hash,"
        " token_hint, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
        (
            user_id,
            name or "agent",
            session.get("email", ""),
            role,
            hash_key(token),
            mcp_token_hint(token),
            now,
        ),
    )
    await db.commit()
    token_id = cursor.lastrowid
    return {
        "id": token_id,
        "name": name or "agent",
        "user_email": session.get("email", ""),
        "system_role": role,
        "token": token,  # plaintext — ровно один раз
        "token_hint": mcp_token_hint(token),
        "created_at": now,
        "last_used_at": None,
        "revoked_at": None,
    }


@router.get("/me/mcp_tokens")
async def list_my_mcp_tokens(request: Request) -> dict:
    """Свои ключи (включая отозванные — так видно историю)."""
    session = await require_session(request)
    db = get_db()
    cursor = await db.execute(
        "SELECT * FROM mcp_tokens WHERE user_id = ? ORDER BY id DESC",
        (session["user_id"],),
    )
    rows = await cursor.fetchall()
    active = sum(1 for r in rows if not r["revoked_at"])
    return {"tokens": [_row_out(r) for r in rows], "active_count": active, "limit": ACTIVE_LIMIT}


@router.post("/me/mcp_tokens", status_code=201)
async def create_mcp_token(request: Request, payload: TokenCreate) -> dict:
    """Выдать персональный ключ; в ответе — plaintext единственный раз."""
    session = await require_session(request)
    now = time.monotonic()
    log = [t for t in _create_log.get(session["user_id"], []) if now - t < _CREATE_WINDOW]
    if len(log) >= _CREATE_MAX:
        raise HTTPException(status_code=429, detail="too many tokens issued, retry later")
    log.append(now)
    _create_log[session["user_id"]] = log
    return await _issue(session, payload.name.strip())


@router.post("/me/mcp_tokens/{token_id}/revoke")
async def revoke_mcp_token(token_id: int, request: Request) -> dict:
    """Отозвать свой ключ (не удаляем — строка остаётся в списках с хвост-хинтом)."""
    session = await require_session(request)
    db = get_db()
    cursor = await db.execute(
        "SELECT id, revoked_at FROM mcp_tokens WHERE id = ? AND user_id = ?",
        (token_id, session["user_id"]),
    )
    row = await cursor.fetchone()
    if row is None:
        raise HTTPException(status_code=404, detail="token not found")
    if not row["revoked_at"]:
        await db.execute(
            "UPDATE mcp_tokens SET revoked_at = ? WHERE id = ?", (now_iso(), token_id)
        )
        await db.commit()
    return {"status": "ok"}


# --- Админ: все ключи всех пользователей --------------------------------------

admin_router = APIRouter(prefix="/api/v1/admin", dependencies=[Depends(require_admin)])


@admin_router.get("/mcp_tokens")
async def list_all_mcp_tokens() -> list[dict]:
    """Все ключи всех пользователей (включая отозванные) — новые сверху."""
    db = get_db()
    cursor = await db.execute("SELECT * FROM mcp_tokens ORDER BY id DESC")
    return [_row_out(r) for r in await cursor.fetchall()]


@admin_router.post("/mcp_tokens/{token_id}/revoke")
async def admin_revoke_mcp_token(token_id: int) -> dict:
    db = get_db()
    cursor = await db.execute("SELECT id, revoked_at FROM mcp_tokens WHERE id = ?", (token_id,))
    row = await cursor.fetchone()
    if row is None:
        raise HTTPException(status_code=404, detail="token not found")
    if not row["revoked_at"]:
        await db.execute(
            "UPDATE mcp_tokens SET revoked_at = ? WHERE id = ?", (now_iso(), token_id)
        )
        await db.commit()
    return {"status": "ok"}