"""Персональные 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"}