diff --git a/.env.example b/.env.example index 201d450..6d1a4c3 100644 --- a/.env.example +++ b/.env.example @@ -39,7 +39,9 @@ # S2S_SECRET_NAVI_REI= # --- MCP (docs/07) --- -# Bearer-токен MCP-эндпоинта для ИИ-агента. Пусто/не задано — /mcp выключен. +# Статический Bearer-токен суперадмин-бэкдора для /mcp: совпадает с текущими +# сценариями агентов (полный доступ). ОПЦИОНАЛЕН: /mcp монтируется всегда, +# персональные ключи mcp_... пользователи выпускают себе на SPA /mcp-keys. # Генерация: openssl rand -hex 32. Токен — как API-ключ: в .env + gnexus-creds. MCP_TOKEN= diff --git a/CLAUDE.md b/CLAUDE.md index afd7f46..38a9e80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ - **Доступ к админке** — роли gnexus-auth (SSO): настраивать Synapse — только `admin` и выше (админ-API под 403). Пользователь роли `user` **входит в приложение** с ограниченным личным разделом «Мои события» (`/api/v1/me`; в админку не пускается ни сервером, ни роутером SPA). Своих паролей Synapse не хранит. - **Многопользовательность (2026-10-03)**: «о ком событие» — конвенция `payload.user_id` (= `sub` gnexus-auth; контракт v1 цел, получателей в конверте нет). Что видит пользователь — решают правила: целевые каналы `user` (личный лог) и `push` (web-push) без цели, адресат из `payload.user_id`; правило без них — событие «только для админа». Привязка «user_id ↔ адрес канала» для push — самопользовательская: браузер подписывается в разделе «Настройки» (docs/06). Остался открытый вопрос №3 по tg chat_id/email opt-in. В OAuth-колбэке токен выдаётся любому аутентифицированному (revoke не-админу больше не нужен). - **Настройки + PWA (2026-10-03)**: редактируемые параметры — реестр `app/settings_registry.py`, дефолт из `.env`, оверрайды в таблице `app_settings` через админку (применяются без перезапуска; docs/06). Секреты (VAPID private, тг-токен, SMTP-пароль) — в БД write-only: записываются через UI, никогда не возвращаются API, читает только воркер; исключение из правила «секреты только в .env», согласовано владельцем. Канал `push` реализован: pywebpush (DER base64url ключи, 404/410 → подписка удаляется), таблица `push_subscriptions`, PWA (manifest + sw.js) в дистрибутиве SPA. -- **MCP + архив вместо удаления (2026-10-03)**: embedded MCP-сервер (FastMCP, streamable-http) на `/mcp` того же контейнера api; доступ — статический Bearer `MCP_TOKEN` из `.env` (пусто → /mcp не монтируется). Тулы (31, docs/07) переиспользуют admin-route функции напрямую (та же валидация, автор — синтетический superadmin): источники + **выдача API-ключей клиентам** (plaintext один раз), типы, цели, правила, поток, настройки; `send_test_event` — контрольный прогон правила. Удаление справочников (sources/types/targets/rules) = архив: `deleted_at`, restore-эндпоинты, списки с `include_archived`, create поверх архива → 409 с подсказкой; архивный источник/тип не принимают события (401/422), архивная цель — доставки `skipped` с аудитом; push-подписки — исключение, удаляются физически. docs/07-mcp.md. +- **MCP + архив вместо удаления (2026-10-03)**: embedded MCP-сервер (FastMCP, streamable-http) на `/mcp` того же контейнера api; доступ — статический Bearer `MCP_TOKEN` из `.env` (суперадмин-бэкдор; пусто → работают только персональные ключи). **Персональные MCP-токены (2026-10-04)**: `mcp_*` — каждый пользователь выпускает себе на SPA-странице `/mcp-keys` (plaintext один раз, sha256 в `mcp_tokens`, ≤10 активных); MCP-запрос действует ролью владельца на момент выпуска (снейпшот) — role user только личный набор (me, my_events_list, my_push_*), admin+ весь каталог (35). Админ-обзор и отзыв чужих — там же (/mcp-keys, `/api/v1/admin/mcp_tokens`). SSO-логаут MCP-токены не трогает. Тулы (35, docs/07) переиспользуют admin/me-route функции напрямую (та же валидация): источники + **выдача API-ключей клиентам** (plaintext один раз), типы, цели, правила, поток, настройки; личный набор; `send_test_event` — контрольный прогон правила. Удаление справочников (sources/types/targets/rules) = архив: `deleted_at`, restore-эндпоинты, списки с `include_archived`, create поверх архива → 409 с подсказкой; архивный источник/тип не принимают события (401/422), архивная цель — доставки `skipped` с аудитом; push-подписки — исключение, удаляются физически. docs/07-mcp.md. - **Клиентские библиотеки (2026-10-04)**: тонкие клиенты (без ретраев, очередей, framework-драйверов), весь повторяемый шаблон внутри: env-конфиг (`SYNAPSE_URL`, `SYNAPSE_API_KEY`, `SYNAPSE_TIMEOUT`, `SYNAPSE_DEFAULT_SOURCE` — имена фиксированы экосистемно), сборка+локальная валидация конверта, типизированные исключения, fire-and-forget `emit`, batch, статус. Репы: `gn-synapse-client-py` (PyPI-имя `gnexus-synapse`, зеркалогаут gnexus-gauth) и `gn-synapse-client-php` (Composer `gnexus/synapse-client`, PSR-4 `GNexus\Synapse\`, PSR-18/PSR-17 инжектный транспорт); установка через git.gnexus.space по тегу v0.1.0. Локальный `ValidationException` с statusCode=null — ошибка кода; `default_source` смягчает 403 анти-спуфинга. docs/05 → «Клиентские библиотеки». ## Инфраструктура diff --git a/alembic/versions/20261004_0008_mcp_tokens.py b/alembic/versions/20261004_0008_mcp_tokens.py new file mode 100644 index 0000000..cd88180 --- /dev/null +++ b/alembic/versions/20261004_0008_mcp_tokens.py @@ -0,0 +1,41 @@ +"""Персональные MCP-токены (mcp_) — доступ к /mcp по роли владельца + +Revision ID: c9a2d6e71f43 +Revises: b7d3e8f04a92 +Create Date: 2026-10-04 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "c9a2d6e71f43" +down_revision = "b7d3e8f04a92" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "mcp_tokens", + sa.Column("id", sa.Integer(), primary_key=True), + # sub gnexus-auth владельца: выпуск только себе (авторизован владелец) + sa.Column("user_id", sa.String(64), nullable=False), + sa.Column("name", sa.String(120), nullable=False), + # снейпшоты выпуска — для списков владельца и админа + sa.Column("user_email", sa.String(255), nullable=True), + sa.Column("system_role", sa.String(16), nullable=False), + sa.Column("token_hash", sa.String(64), nullable=False), + sa.Column("token_hint", sa.String(8), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_mcp_tokens_user_id", "mcp_tokens", ["user_id"]) + # уникальность хэша сама не даёт частичный индекс не нужен: + # отозванный хэш остаётся в таблице (история ключей), коллизий нет + op.create_unique_constraint("uq_mcp_tokens_token_hash", "mcp_tokens", ["token_hash"]) + + +def downgrade() -> None: + op.drop_table("mcp_tokens") \ No newline at end of file diff --git a/app/api/admin_routes.py b/app/api/admin_routes.py index bb9bddc..bd3d1d3 100644 --- a/app/api/admin_routes.py +++ b/app/api/admin_routes.py @@ -22,6 +22,7 @@ ChannelTarget, Delivery, Event, + McpToken, NotificationType, RoutingRule, RoutingRuleAction, @@ -29,6 +30,7 @@ ) from app.api.admin_schemas import ( AdminEventOut, + AdminMcpTokenOut, DeliveryOut, KeyCreated, KeyIn, @@ -784,3 +786,55 @@ updated.append(key) db.commit() return {"ok": True, "updated": updated} + + +# --- MCP-токены (персональные, /mcp-keys; выпускает себе сам владелец) --- + +@router.get("/mcp_tokens", response_model=list[AdminMcpTokenOut]) +def list_mcp_tokens( + user_id: str | None = None, + include_revoked: bool = True, + limit: int = Query(200, ge=1, le=1000), + user: AuthenticatedUser = Depends(require_admin), + db: Session = Depends(get_db), +) -> list[AdminMcpTokenOut]: + """Все MCP-токены всех пользователей (выпуск — только на /api/v1/me, + здесь — просмотр и отзыв, например после смены роли пользователя).""" + q = select(McpToken).order_by(McpToken.created_at.desc(), McpToken.id.desc()) + if user_id: + q = q.where(McpToken.user_id == user_id) + if not include_revoked: + q = q.where(McpToken.revoked_at.is_(None)) + rows = db.execute(q.limit(limit)).scalars().all() + return [ + AdminMcpTokenOut( + id=r.id, user_id=r.user_id, user_email=r.user_email, name=r.name, + system_role=r.system_role, token_hint=r.token_hint, + created_at=r.created_at, last_used_at=r.last_used_at, + revoked_at=r.revoked_at, + ) + for r in rows + ] + + +@router.post("/mcp_tokens/{token_id}/revoke", response_model=AdminMcpTokenOut) +def revoke_mcp_token( + token_id: int, + user: AuthenticatedUser = Depends(require_admin), + db: Session = Depends(get_db), +) -> AdminMcpTokenOut: + """Отозвать MCP-токен любого пользователя (его /me-ревоков нет — только свой).""" + row = db.get(McpToken, token_id) + if row is None: + raise HTTPException(status_code=404, detail="Токен не найден") + if row.revoked_at is not None: + raise _conflict("Токен уже отозван") + row.revoked_at = datetime.now(UTC) + db.commit() + db.refresh(row) + return AdminMcpTokenOut( + id=row.id, user_id=row.user_id, user_email=row.user_email, name=row.name, + system_role=row.system_role, token_hint=row.token_hint, + created_at=row.created_at, last_used_at=row.last_used_at, + revoked_at=row.revoked_at, + ) diff --git a/app/api/admin_schemas.py b/app/api/admin_schemas.py index 2698250..291c857 100644 --- a/app/api/admin_schemas.py +++ b/app/api/admin_schemas.py @@ -47,6 +47,22 @@ token: str +# --- MCP-токены (персональные, /mcp-keys; владелец — user_id) --- + +class AdminMcpTokenOut(BaseModel): + """Админский список: владелец (sub/email снейпшот) + роль снейпшота выпуска.""" + + id: int + user_id: str + user_email: str | None + name: str + system_role: str + token_hint: str + created_at: datetime + last_used_at: datetime | None + revoked_at: datetime | None + + # --- types --- class TypeIn(BaseModel): diff --git a/app/api/me_routes.py b/app/api/me_routes.py index dbcd6d2..a9c63d2 100644 --- a/app/api/me_routes.py +++ b/app/api/me_routes.py @@ -8,23 +8,27 @@ """ import uuid -from datetime import datetime +from datetime import UTC, datetime from fastapi import Depends, HTTPException, Query, Response, status from fastapi import APIRouter from pydantic import BaseModel, Field -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session +from app.auth.apikeys import generate_mcp_token, token_hash_of from app.auth.deps import get_current_user from app.config import get_settings from app.database import get_db from app.api.routes import AdminMe -from app.models import Delivery, Event, PushSubscription, Source +from app.models import Delivery, Event, McpToken, PushSubscription, Source from app.settings_store import get_setting_row router = APIRouter(prefix="/api/v1", tags=["me"]) +# Сколько активных (не отозванных) MCP-ключей можно иметь одновременно. +MCP_TOKEN_ACTIVE_LIMIT = 10 + class MeEventOut(BaseModel): id: uuid.UUID @@ -125,6 +129,117 @@ break return out +# --- Персональные MCP-токены (доступ к /mcp, docs/07; модель ApiKey) --- + +class McpTokenOut(BaseModel): + """Plaintext никогда не возвращается (только token_hint — хвост).""" + + id: int + name: str + system_role: str + token_hint: str + created_at: datetime + last_used_at: datetime | None + revoked_at: datetime | None + + +class McpTokenCreated(McpTokenOut): + """Токен в поле token — показывается ОДИН РАЗ (хэш — в БД).""" + + token: str + + +class McpTokenIn(BaseModel): + name: str = Field(min_length=1, max_length=120) + + +def _mcp_token_out(row: McpToken) -> McpTokenOut: + return McpTokenOut( + id=row.id, name=row.name, system_role=row.system_role, + token_hint=row.token_hint, created_at=row.created_at, + last_used_at=row.last_used_at, revoked_at=row.revoked_at, + ) + + +@router.get("/me/mcp_tokens", response_model=list[McpTokenOut]) +def my_mcp_tokens( + user=Depends(get_current_user), + db: Session = Depends(get_db), +) -> list[McpTokenOut]: + """MCP-токены текущего пользователя (его ключи, включая отозванные).""" + return my_mcp_token_rows(str(user.user_id), db) + + +def my_mcp_token_rows(user_id: str, db: Session) -> list[McpTokenOut]: + """Все (включая отозванные) MCP-токены пользователя, новые сверху.""" + rows = ( + db.execute( + select(McpToken) + .where(McpToken.user_id == user_id) + .order_by(McpToken.created_at.desc(), McpToken.id.desc()) + ) + .scalars() + .all() + ) + return [_mcp_token_out(row) for row in rows] + + +@router.post("/me/mcp_tokens", response_model=McpTokenCreated, status_code=201) +def create_mcp_token( + body: McpTokenIn, + user=Depends(get_current_user), + db: Session = Depends(get_db), +) -> McpTokenCreated: + """Выпустить себе MCP-токен; plaintext в ответе ровно один раз. + + Роль снейпшотится: MCP будет действовать этим system_role (смена роли + на gnexus-auth — выпустить ключ заново, старый отозвать).""" + sub = str(user.user_id) + active = db.scalar( + select(func.count(McpToken.id)).where( + McpToken.user_id == sub, McpToken.revoked_at.is_(None) + ) + ) + if active >= MCP_TOKEN_ACTIVE_LIMIT: + raise HTTPException( + status.HTTP_409_CONFLICT, + f"Достигнут лимит активных MCP-токенов ({MCP_TOKEN_ACTIVE_LIMIT}) — отзовите лишний", + ) + token = generate_mcp_token() + row = McpToken( + user_id=sub, + name=body.name, + user_email=user.email or None, + system_role=user.system_role or "unknown", + token_hash=token_hash_of(token), + token_hint=token[-4:], + ) + db.add(row) + db.commit() + db.refresh(row) + return McpTokenCreated( + **_mcp_token_out(row).model_dump(), token=token, + ) + + +@router.post("/me/mcp_tokens/{token_id}/revoke", response_model=McpTokenOut) +def revoke_mcp_token( + token_id: int, + user=Depends(get_current_user), + db: Session = Depends(get_db), +) -> McpTokenOut: + """Отозвать свой MCP-токен; чужой — 404 (как у push-подписок).""" + row = db.get(McpToken, token_id) + if row is None or row.user_id != str(user.user_id): + raise HTTPException(status.HTTP_404_NOT_FOUND, "Токен не найден") + if row.revoked_at is not None: + raise HTTPException(status.HTTP_409_CONFLICT, "Токен уже отозван") + row.revoked_at = datetime.now(UTC) + db.commit() + db.refresh(row) + return _mcp_token_out(row) + + # --- Web-push подписки (канал push; самопользовательская привязка, docs/06) --- @router.get("/me/push", response_model=MePushOut) diff --git a/app/auth/apikeys.py b/app/auth/apikeys.py index 3c756a6..19341b2 100644 --- a/app/auth/apikeys.py +++ b/app/auth/apikeys.py @@ -23,6 +23,15 @@ return f"syn_{secrets.token_hex(32)}" +def generate_mcp_token() -> str: + """Персональный MCP-токен по той же модели: `mcp_` + 64 hex, plaintext один раз. + + Отдельный префикс от `syn_*` (ключи источников) — чтобы формат говорил, + о каких кредах речь. Хэш/отзыв — те же token_hash_of и revoked_at. + """ + return f"mcp_{secrets.token_hex(32)}" + + def token_hash_of(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() diff --git a/app/main.py b/app/main.py index a08813f..b82b287 100644 --- a/app/main.py +++ b/app/main.py @@ -20,26 +20,26 @@ settings = get_settings() # MCP (docs/07): embedded FastMCP на /mcp streamable-http, гейт по Bearer. - # Пустой MCP_TOKEN — сервера нет (GET /mcp уйдёт в SPA, POST — 405). + # Монтируется всегда: персональные ключи (mcp_...) работают и без + # MCP_TOKEN; статический env-токен — опциональный суперадмин-бэкдор. # Session manager FastMCP требует явного запуска: enter_async_context # в lifespan держит его открытым всё время жизни приложения. - mcp_mount = None + from app.mcp.server import McpTokenGuard, build_mcp_server + + raw_mcp_app = build_mcp_server().streamable_http_app() + mcp_mount = McpTokenGuard(raw_mcp_app, settings.mcp_token or None) + lifespan_kwargs: dict = {} - if settings.mcp_token: - from app.mcp.server import McpTokenGuard, build_mcp_server - raw_mcp_app = build_mcp_server().streamable_http_app() - mcp_mount = McpTokenGuard(raw_mcp_app, settings.mcp_token) + @asynccontextmanager + async def _lifespan(app: FastAPI): + async with AsyncExitStack() as stack: + await stack.enter_async_context( + raw_mcp_app.router.lifespan_context(raw_mcp_app) + ) + yield - @asynccontextmanager - async def _lifespan(app: FastAPI): - async with AsyncExitStack() as stack: - await stack.enter_async_context( - raw_mcp_app.router.lifespan_context(raw_mcp_app) - ) - yield - - lifespan_kwargs["lifespan"] = _lifespan + lifespan_kwargs["lifespan"] = _lifespan app = FastAPI( title=settings.app_name, docs_url="/api/docs", openapi_url="/api/openapi.json", @@ -65,15 +65,14 @@ # Именно Route, не Mount: Mount('/mcp') матчит только '/mcp/...' (regex # с trailing slash), точный POST /mcp провалился бы в SPA (GET index / # POST 405); Route отдаёт в саб-апп путь без root_path-трюков. - if mcp_mount is not None: - from starlette.routing import Route + from starlette.routing import Route - app.router.routes.append( - Route( - "/mcp", mcp_mount, methods=["GET", "POST", "DELETE", "OPTIONS"], - include_in_schema=False, - ) + app.router.routes.append( + Route( + "/mcp", mcp_mount, methods=["GET", "POST", "DELETE", "OPTIONS"], + include_in_schema=False, ) + ) # Собранная SPA: в Docker-образе кладётся в spa_static/. Если собранной # статики нет (локальный запуск api без фронта) — просто пропускаем. diff --git a/app/mcp/context.py b/app/mcp/context.py new file mode 100644 index 0000000..297b78a --- /dev/null +++ b/app/mcp/context.py @@ -0,0 +1,72 @@ +"""Контекст MCP-запроса: какой пользователь за Bearer-ключом. + +McpTokenGuard резолвит токен (см. server.py) и кладёт AuthenticatedUser в +ContextVar — тулы (`app/mcp/tools.py`) читают его через current_mcp_user(): +полный набор тулов действуют ролью владельца ключа, админ-тулы требуют +роли admin (require_mcp_admin). ContextVar прокидывается FastMCP в +threadpool-исполнение тулов, так что sync-функции его видят. + +MCP_USER — прежний синтетический superadmin: остаётся бэкдором статического +env-токена (MCP_TOKEN) и дефолтом для прямых вызовов тулов в тестах. +""" + +from contextvars import ContextVar + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.auth.deps import AuthenticatedUser +from app.models import McpToken + +# Синтетический superadmin: ключ статического env-токена MCP_TOKEN (docs/07). +MCP_USER = AuthenticatedUser( + user_id="mcp", email="mcp@synapse.local", email_verified=True, system_role="superadmin" +) + +_mcp_user: ContextVar[AuthenticatedUser | None] = ContextVar("mcp_user", default=None) + + +def set_mcp_user(user: AuthenticatedUser) -> object: + """Для гейда: установить пользователя на время запроса, вернуть token для reset().""" + return _mcp_user.set(user) + + +def reset_mcp_user(token: object) -> None: + _mcp_user.reset(token) # type: ignore[arg-type] + + +def current_mcp_user() -> AuthenticatedUser: + """Пользователь (или роль) текущего MCP-вызова; None — прямой вызов вне запроса.""" + found = _mcp_user.get() + if found is not None: + return found + return MCP_USER + + +def resolve_personal_token(db: Session, raw_token: str) -> AuthenticatedUser | None: + """Bearer `mcp_...` -> владелец ключа; None — промах/отзыв. + + Вызывается гейтом на каждом запросе /mcp. last_used_at — единственный + столбец, который MCP-путь когда-либо пишет (коммит здесь: у гейта нет + общей транзакции роутера). + """ + from datetime import UTC, datetime + + from app.auth.apikeys import token_hash_of + + row = db.execute( + select(McpToken).where( + McpToken.token_hash == token_hash_of(raw_token), + McpToken.revoked_at.is_(None), + ) + ).scalar_one_or_none() + if row is None: + return None + row.last_used_at = datetime.now(UTC) + db.commit() + return AuthenticatedUser( + user_id=row.user_id, + email=row.user_email or "", + email_verified=True, + system_role=row.system_role, + ) \ No newline at end of file diff --git a/app/mcp/server.py b/app/mcp/server.py index 10e0dd9..a9ed410 100644 --- a/app/mcp/server.py +++ b/app/mcp/server.py @@ -1,13 +1,18 @@ """Сборка MCP-сервера Synapse (docs/07). FastMCP (stateless_http) — Starlette-приложение streamable-http; поверх — -McpTokenGuard: статический Bearer-токен из MCP_TOKEN (.env). Токен один, -выдаётся администратором (генерация `openssl rand -hex 32`) — полномочия -тулов = superadmin внутри API, так что токен держать как ключ прод-сервера. +McpTokenGuard. Два вида Bearer-ключей: + +- статический `MCP_TOKEN` из .env — суперадмин-бэкдор (совместимость с + сценариями агентов до персональных ключей; токен держать как ключ прод-сервера); +- персональный `mcp_...` — выдаётся пользователем самому себе на SPA + /mcp-keys; MCP-запрос действует ролью владельца (админ-тулы — только + для ролей admin/superadmin, остальное — личный набор). """ from mcp.server.fastmcp import FastMCP +from app.mcp.context import MCP_USER, reset_mcp_user, resolve_personal_token, set_mcp_user from app.mcp.tools import register_tools @@ -18,8 +23,10 @@ instructions=( "Управление хабом уведомлений Gnexus Synapse: источники и выдача " "API-ключей, типы уведомлений, цели каналов, правила маршрутизации, " - "поток событий и доставок, настройки. Удаление = архив (restore " - "возвращает); секреты настроек write-only." + "поток событий и доставок, настройки. Персональный ключ действует " + "ролью владельца: роль user — личный набор (me, my_events_list, " + "my_push_*). Удаление = архив (restore возвращает); секреты настроек " + "write-only." ), ) register_tools(mcp) @@ -27,31 +34,48 @@ class McpTokenGuard: - """Чистый ASGI-гейт (не BaseHTTPMiddleware): проверяет Bearer-токен на - каждом http-запросе. Не BaseHTTPMiddleware — чтобы не ломать SSE-стримы.""" + """Чистый ASGI-гейт (не BaseHTTPMiddleware): резолвит Bearer-токен на + каждом http-запросе. Не BaseHTTPMiddleware — чтобы не ломать SSE-стримы. - def __init__(self, app, token: str) -> None: + Резолв персонального ключа — короткий indexed lookup в БД прямо в + event loop-е (миллисекунды; у гейта нет threadpool-обёртки, как у тулов). + """ + + def __init__(self, app, token: str | None) -> None: self.app = app - self.expected = f"Bearer {token}".encode() + self.static_expected = f"Bearer {token}".encode() if token else None async def __call__(self, scope, receive, send): if scope["type"] != "http": await self.app(scope, receive, send) return headers = {k.lower(): v for k, v in scope.get("headers") or []} - if headers.get(b"authorization") == self.expected: - await self.app(scope, receive, send) + auth = headers.get(b"authorization", b"") + user = None + if self.static_expected is not None and auth == self.static_expected: + user = MCP_USER # суперадмин-бэкдор из .env + elif auth.startswith(b"Bearer mcp_"): + from app.database import SessionLocal + + with SessionLocal() as db: + user = resolve_personal_token(db, auth[len("Bearer "):].strip().decode("utf-8", "replace")) + if user is None: + body = b'{"detail":"Invalid or missing MCP token"}' + await send( + { + "type": "http.response.start", + "status": 401, + "headers": [ + (b"content-type", b"application/json"), + (b"www-authenticate", b"Bearer"), + (b"content-length", str(len(body)).encode()), + ], + } + ) + await send({"type": "http.response.body", "body": body}) return - body = b'{"detail":"Invalid or missing MCP token"}' - await send( - { - "type": "http.response.start", - "status": 401, - "headers": [ - (b"content-type", b"application/json"), - (b"www-authenticate", b"Bearer"), - (b"content-length", str(len(body)).encode()), - ], - } - ) - await send({"type": "http.response.body", "body": body}) \ No newline at end of file + ctx_token = set_mcp_user(user) + try: + await self.app(scope, receive, send) + finally: + reset_mcp_user(ctx_token) \ No newline at end of file diff --git a/app/mcp/tools.py b/app/mcp/tools.py index ab4043e..f6875b9 100644 --- a/app/mcp/tools.py +++ b/app/mcp/tools.py @@ -3,14 +3,21 @@ Каждый тул — синхронная функция (FastMCP гоняет её в threadpool — совместимо с синхронной SQLAlchemy-сессией). Логика записи НЕ дублируется: тулы переиспользуют admin-route функции (admin_routes.py) напрямую с -user=MCP_USER, db=<сессия> — Depends не срабатывают, та же валидация и те же -коды ошибок. HTTPException -> {"error": status, "detail"}. +user=current_mcp_user(), db=<сессия> — Depends не срабатывают, та же +валидация и те же коды ошибок. HTTPException -> {"error": status, "detail"}. + +Роли (docs/07): ключ действует ролью владельца. Админ-тулы под +_admin_run — для персонального ключа роли user: {"error": 403}. +Личный набор (me, my_events_list, my_push_*) — любая роль, всегда +отфильтрован по sub владельца (me_routes) или read-only. """ +import json import uuid from typing import Any from fastapi import HTTPException +from mcp.server.fastmcp.exceptions import ToolError from pydantic import BaseModel, ValidationError from sqlalchemy import func, select, text @@ -25,9 +32,9 @@ TargetPatch, TypeIn, ) -from app.auth.deps import AuthenticatedUser from app.config import get_settings from app.database import SessionLocal +from app.mcp.context import current_mcp_user from app.models import ( ChannelTarget, Delivery, @@ -39,11 +46,6 @@ ) from app.worker.celery_app import celery_app -# Синтетический superadmin: require_admin пропускает, автор — MCP-токен. -MCP_USER = AuthenticatedUser( - user_id="mcp", email="mcp@synapse.local", email_verified=True, system_role="superadmin" -) - LIMIT_CAP = 500 @@ -58,6 +60,26 @@ return {"error": 422, "detail": err.errors()[0].get("msg", "невалидно")} +def _admin_denied() -> dict | None: + """Роль владельца ключа — не admin/superadmin? -> dict ошибки 403.""" + user = current_mcp_user() + if (user.system_role or "") in ("admin", "superadmin"): + return None + return { + "error": 403, + "detail": "Тул уровня админа — нужен MCP-ключ роли admin (выпустите заново на /mcp-keys)", + } + + +def _admin_run(fn) -> Any: + denial = _admin_denied() + if denial is not None: + # ToolError, а не return: FastMCP валидирует вывод по аннотации + # (list-тулы бы отвергли dict ошибки), isError несёт текст как есть. + raise ToolError(json.dumps(denial, ensure_ascii=False)) + return _run(fn) + + def _patch(model_cls: type[BaseModel], **kwargs) -> BaseModel: """PATCH-схема только из переданных полей: None = «не менять» (exclude_unset в route-функции видит незаданные поля незаданными).""" @@ -72,7 +94,7 @@ def fn(db): db.execute(text("SELECT 1")) return {"database": "ok"} - out = _run(fn) + out = _admin_run(fn) if "error" in out: return {"database": "unavailable", **out} try: @@ -116,7 +138,7 @@ select(func.count(RoutingRule.id)).where(RoutingRule.deleted_at.is_(None)) ), } - return _run(fn) + return _admin_run(fn) # --- источники и ключи (выдача ключей клиентам, docs/07) --- @@ -128,7 +150,7 @@ include_archived=true — показать и заархивированные (deleted_at != null).""" def fn(db): rows = admin_routes.list_sources( - include_archived=include_archived, user=MCP_USER, db=db + include_archived=include_archived, user=current_mcp_user(), db=db ) out = [r.model_dump(mode="json") for r in rows] if query: @@ -140,7 +162,7 @@ or ql in (r.get("description") or "").lower() ] return out[: max(1, min(limit, LIMIT_CAP))] - return _run(fn) + return _admin_run(fn) @mcp.tool() def source_create(name: str, label: str | None = None, description: str | None = None) -> dict: @@ -149,36 +171,36 @@ def fn(db): src = admin_routes.create_source( SourceIn(name=name, label=label, description=description), - user=MCP_USER, db=db, + user=current_mcp_user(), db=db, ) return src.model_dump(mode="json") - return _run(fn) + return _admin_run(fn) @mcp.tool() def source_archive(source_id: int) -> dict: """Заархивировать источник (это не удаление): ключ перестаёт принимать события (401), source_restore возвращает в строй.""" def fn(db): - admin_routes.delete_source(source_id, user=MCP_USER, db=db) + admin_routes.delete_source(source_id, user=current_mcp_user(), db=db) return {"ok": True, "archived": source_id} - return _run(fn) + return _admin_run(fn) @mcp.tool() def source_restore(source_id: int) -> dict: """Вернуть заархивированный источник в строй. Конфликт имени → 409.""" def fn(db): - src = admin_routes.restore_source(source_id, user=MCP_USER, db=db) + src = admin_routes.restore_source(source_id, user=current_mcp_user(), db=db) return src.model_dump(mode="json") - return _run(fn) + return _admin_run(fn) @mcp.tool() def keys_list(source_id: int) -> list[dict]: """API-ключи источника: хэш и хвост — plaintext никогда не возвращается, полный токен виден один раз в key_issue.""" def fn(db): - keys = admin_routes.list_keys(source_id, user=MCP_USER, db=db) + keys = admin_routes.list_keys(source_id, user=current_mcp_user(), db=db) return [k.model_dump(mode="json") for k in keys] - return _run(fn) + return _admin_run(fn) @mcp.tool() def key_issue(source_id: int, name: str) -> dict: @@ -187,18 +209,18 @@ в Synapse остаётся только хэш. Повторный вызов = новый токен.""" def fn(db): created = admin_routes.create_key( - source_id, KeyIn(name=name), user=MCP_USER, db=db + source_id, KeyIn(name=name), user=current_mcp_user(), db=db ) return created.model_dump(mode="json") - return _run(fn) + return _admin_run(fn) @mcp.tool() def key_revoke(source_id: int, key_id: int) -> dict: """Отозвать API-ключ источника (события с ним начнут получать 401).""" def fn(db): - key = admin_routes.revoke_key(source_id, key_id, user=MCP_USER, db=db) + key = admin_routes.revoke_key(source_id, key_id, user=current_mcp_user(), db=db) return key.model_dump(mode="json") - return _run(fn) + return _admin_run(fn) # --- типы уведомлений (тройка source/subject/action) --- @@ -213,7 +235,7 @@ Событие с незарегистрированной тройкой приём отклоняет (422).""" def fn(db): rows = admin_routes.list_types( - include_archived=include_archived, user=MCP_USER, db=db + include_archived=include_archived, user=current_mcp_user(), db=db ) out = [r.model_dump(mode="json") for r in rows] if source_id is not None: @@ -227,7 +249,7 @@ or ql in (r.get("description") or "").lower() ] return out[: max(1, min(limit, LIMIT_CAP))] - return _run(fn) + return _admin_run(fn) @mcp.tool() def type_register( @@ -245,27 +267,27 @@ source_id=source_id, subject=subject, action=action, payload_schema=payload_schema, description=description, ), - user=MCP_USER, db=db, + user=current_mcp_user(), db=db, ) return nt.model_dump(mode="json") - return _run(fn) + return _admin_run(fn) @mcp.tool() def type_archive(type_id: int) -> dict: """Заархивировать тип: приём его тройки начнёт отвечать 422 «тип не зарегистрирован»; type_restore вернёт в строй.""" def fn(db): - admin_routes.delete_type(type_id, user=MCP_USER, db=db) + admin_routes.delete_type(type_id, user=current_mcp_user(), db=db) return {"ok": True, "archived": type_id} - return _run(fn) + return _admin_run(fn) @mcp.tool() def type_restore(type_id: int) -> dict: """Вернуть заархивированный тип в строй (источник должен быть жив).""" def fn(db): - nt = admin_routes.restore_type(type_id, user=MCP_USER, db=db) + nt = admin_routes.restore_type(type_id, user=current_mcp_user(), db=db) return nt.model_dump(mode="json") - return _run(fn) + return _admin_run(fn) # --- цели каналов --- @@ -279,7 +301,7 @@ """Цели каналов (TG-чат, email-адрес, s2s-точка). channel — фильтр.""" def fn(db): rows = admin_routes.list_targets( - include_archived=include_archived, user=MCP_USER, db=db + include_archived=include_archived, user=current_mcp_user(), db=db ) out = [r.model_dump(mode="json") for r in rows] if channel: @@ -287,7 +309,7 @@ if enabled is not None: out = [r for r in out if r["enabled"] == enabled] return out[: max(1, min(limit, LIMIT_CAP))] - return _run(fn) + return _admin_run(fn) @mcp.tool() def target_create( @@ -299,10 +321,10 @@ def fn(db): t = admin_routes.create_target( TargetIn(channel=channel, name=name, config=config, description=description), - user=MCP_USER, db=db, + user=current_mcp_user(), db=db, ) return t.model_dump(mode="json") - return _run(fn) + return _admin_run(fn) @mcp.tool() def target_patch( @@ -316,27 +338,27 @@ target_id, _patch(TargetPatch, name=name, description=description, config=config, enabled=enabled), - user=MCP_USER, db=db, + user=current_mcp_user(), db=db, ) return t.model_dump(mode="json") - return _run(fn) + return _admin_run(fn) @mcp.tool() def target_archive(target_id: int) -> dict: """Заархивировать цель: правила продолжат матчить, но доставки в неё запишутся skipped «цель в архиве»; restore вернёт в строй.""" def fn(db): - admin_routes.delete_target(target_id, user=MCP_USER, db=db) + admin_routes.delete_target(target_id, user=current_mcp_user(), db=db) return {"ok": True, "archived": target_id} - return _run(fn) + return _admin_run(fn) @mcp.tool() def target_restore(target_id: int) -> dict: """Вернуть заархивированную цель в строй. Конфликт имени → 409.""" def fn(db): - t = admin_routes.restore_target(target_id, user=MCP_USER, db=db) + t = admin_routes.restore_target(target_id, user=current_mcp_user(), db=db) return t.model_dump(mode="json") - return _run(fn) + return _admin_run(fn) # --- правила маршрутизации --- @@ -346,12 +368,12 @@ ) -> list[dict]: """Правила маршрутизации (условия -> действия). weight — порядок в режиме first.""" def fn(db): - rows = admin_routes.list_rules(include_archived=include_archived, user=MCP_USER, db=db) + rows = admin_routes.list_rules(include_archived=include_archived, user=current_mcp_user(), db=db) out = [r.model_dump(mode="json") for r in rows] if enabled is not None: out = [r for r in out if r["enabled"] == enabled] return out[: max(1, min(limit, LIMIT_CAP))] - return _run(fn) + return _admin_run(fn) @mcp.tool() def rule_create( @@ -373,10 +395,10 @@ "conditions": conditions or {}, "template": template, "throttle_seconds": throttle_seconds, "weight": weight, }), - user=MCP_USER, db=db, + user=current_mcp_user(), db=db, ) return rule.model_dump(mode="json") - return _run(fn) + return _admin_run(fn) @mcp.tool() def rule_patch( @@ -392,26 +414,26 @@ _patch(RulePatch, name=name, enabled=enabled, conditions=conditions, template=template, throttle_seconds=throttle_seconds, weight=weight, actions=actions), - user=MCP_USER, db=db, + user=current_mcp_user(), db=db, ) return rule.model_dump(mode="json") - return _run(fn) + return _admin_run(fn) @mcp.tool() def rule_archive(rule_id: int) -> dict: """Заархивировать правило (действия сохраняются — restore вернёт целиком).""" def fn(db): - admin_routes.delete_rule(rule_id, user=MCP_USER, db=db) + admin_routes.delete_rule(rule_id, user=current_mcp_user(), db=db) return {"ok": True, "archived": rule_id} - return _run(fn) + return _admin_run(fn) @mcp.tool() def rule_restore(rule_id: int) -> dict: """Вернуть заархивированное правило в строй.""" def fn(db): - rule = admin_routes.restore_rule(rule_id, user=MCP_USER, db=db) + rule = admin_routes.restore_rule(rule_id, user=current_mcp_user(), db=db) return rule.model_dump(mode="json") - return _run(fn) + return _admin_run(fn) # --- поток: события и доставки --- @@ -429,18 +451,18 @@ ).scalar_one_or_none() source_id = src if src is not None else -1 # нет источника — пусто rows = admin_routes.list_events( - user=MCP_USER, db=db, limit=limit, status_filter=status, source_id=source_id + user=current_mcp_user(), db=db, limit=limit, status_filter=status, source_id=source_id ) return [r.model_dump(mode="json") for r in rows] - return _run(fn) + return _admin_run(fn) @mcp.tool() def event_get(event_id: str) -> dict: """Событие по id: конверт, статусы и доставки в каналы.""" def fn(db): - ev = admin_routes.get_event(uuid.UUID(event_id), user=MCP_USER, db=db) + ev = admin_routes.get_event(uuid.UUID(event_id), user=current_mcp_user(), db=db) return ev.model_dump(mode="json") - return _run(fn) + return _admin_run(fn) @mcp.tool() def deliveries_list( @@ -450,10 +472,10 @@ status ∈ pending|delivered|skipped|failed.""" def fn(db): rows = admin_routes.list_deliveries( - user=MCP_USER, db=db, limit=limit, status_filter=status, channel=channel + user=current_mcp_user(), db=db, limit=limit, status_filter=status, channel=channel ) return [r.model_dump(mode="json") for r in rows] - return _run(fn) + return _admin_run(fn) @mcp.tool() def push_subscriptions_list(user_id: str | None = None, limit: int = 100) -> list[dict]: @@ -471,7 +493,7 @@ } for s in rows ] - return _run(fn) + return _admin_run(fn) @mcp.tool() def push_subscription_delete(subscription_id: int) -> dict: @@ -484,7 +506,7 @@ db.delete(sub) db.commit() return {"ok": True, "deleted": subscription_id} - return _run(fn) + return _admin_run(fn) # --- настройки --- @@ -493,9 +515,9 @@ """Реестр настроек Synapse: дефолт .env + оверрайды админки. Секреты write-only — value всегда null, факт наличия в set.""" def fn(db): - rows = admin_routes.list_settings(user=MCP_USER, db=db) + rows = admin_routes.list_settings(user=current_mcp_user(), db=db) return [r.model_dump(mode="json") for r in rows] - return _run(fn) + return _admin_run(fn) @mcp.tool() def settings_put(values: dict) -> dict: @@ -503,8 +525,51 @@ null — вернуть дефолт .env, "" у секрета — «не менять». Нарушение — 422.""" def fn(db): return admin_routes.put_settings( - SettingsPut.model_validate({"values": values}), user=MCP_USER, db=db + SettingsPut.model_validate({"values": values}), user=current_mcp_user(), db=db ) + return _admin_run(fn) + + # --- личный набор (любая роль, фильтр по sub владельца ключа; docs/07) --- + + @mcp.tool() + def me() -> dict: + """Кто за ключом: sub/email/роль снейпшота выпуска MCP-токена.""" + user = current_mcp_user() + return { + "user_id": str(user.user_id), + "email": user.email, + "system_role": user.system_role, + } + + @mcp.tool() + def my_events_list(limit: int = 50) -> list[dict]: + """События, доставленные владельцу ключа лично (канал user, + адресат payload.user_id == sub). Тот же путь, что «Мои события» SPA.""" + from app.api.me_routes import my_events + + def fn(db): + rows = my_events(user=current_mcp_user(), db=db, limit=max(1, min(limit, 200))) + return [r.model_dump(mode="json") for r in rows] + return _run(fn) + + @mcp.tool() + def my_push_subscriptions_list() -> list[dict]: + """Web-push подписки владельца ключа (его браузерные устройства).""" + from app.api.me_routes import my_subscriptions + + def fn(db): + rows = my_subscriptions(user=current_mcp_user(), db=db) + return [r.model_dump(mode="json") for r in rows] + return _run(fn) + + @mcp.tool() + def my_push_subscription_delete(subscription_id: int) -> dict: + """Отписать СВОЮ web-push подписку (чужая — 404); жёсткое удаление устройства.""" + from app.api.me_routes import remove_subscription + + def fn(db): + remove_subscription(subscription_id, user=current_mcp_user(), db=db) + return {"ok": True, "deleted": subscription_id} return _run(fn) # --- приём контрольного события --- @@ -538,4 +603,4 @@ db.commit() celery_app.send_task("synapse.ingest", args=[str(event.id)]) return {"id": str(event.id), "status": event.status, "source": source_name} - return _run(fn) \ No newline at end of file + return _admin_run(fn) \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py index a53b28d..3227e6f 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,6 +1,6 @@ """SQLAlchemy-модели Synapse. Все модули импортируются для Base.metadata.""" -from app.models.dicts import ApiKey, ChannelTarget, NotificationType, Source +from app.models.dicts import ApiKey, ChannelTarget, McpToken, NotificationType, Source from app.models.events import Delivery, Event from app.models.rules import RoutingRule, RoutingRuleAction from app.models.settings import AppSetting, PushSubscription @@ -8,6 +8,7 @@ __all__ = [ "Source", "ApiKey", + "McpToken", "NotificationType", "ChannelTarget", "RoutingRule", diff --git a/app/models/dicts.py b/app/models/dicts.py index 055ffad..ee9fade 100644 --- a/app/models/dicts.py +++ b/app/models/dicts.py @@ -81,6 +81,39 @@ revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) +class McpToken(Base): + """Персональный MCP-токен (`Authorization: Bearer mcp_...`, docs/07). + + Выдаётся любому аутентифицированному пользователю самому себе + (SPA /mcp-keys, plaintext один раз — модель ApiKey). Роль владельца + снимается снейпшотом при выпуске: MCP-запрос действует этой ролью — + тулам уровня админа нужен ключ роли admin; user-ключу доступен + личный набор. Изменение роли на gnexus-auth сюда не дотягивается — + ключ выпускается заново. + + Токены — не справочники: архива нет, только revoke; списки показывают + хэш-хвост, plaintext никогда не возвращается. + """ + + __tablename__ = "mcp_tokens" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + # sub gnexus-auth владельца (нельзя выпускать на чужого — авторизован владелец) + user_id: Mapped[str] = mapped_column(String(64), index=True) + # человекочитаемое назначение («агент Navi rei») + name: Mapped[str] = mapped_column(String(120)) + # снейпшоты выпуска — для списков админа и владельца (sub в колонке user_id) + user_email: Mapped[str | None] = mapped_column(String(255)) + system_role: Mapped[str] = mapped_column(String(16)) + token_hash: Mapped[str] = mapped_column(String(64), unique=True) + token_hint: Mapped[str] = mapped_column(String(8)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + class NotificationType(Base): """Тип уведомления = тройка (source, subject, action) из контракта.""" diff --git a/docs/07-mcp.md b/docs/07-mcp.md index 370fce4..c828bb2 100644 --- a/docs/07-mcp.md +++ b/docs/07-mcp.md @@ -1,46 +1,62 @@ # 07 — MCP-сервер: управление Synapse ИИ-агентом Synapse встраивает **FastMCP** (пакет `mcp`) — тот же контейнер API, тот же -процесс uvicorn. Эндпоинт `/mcp`, транспорт **streamable-http** (stateless), -доступ — статический Bearer-токен `MCP_TOKEN` из `.env`. +процесс uvicorn. Эндпоинт `/mcp`, транспорт **streamable-http** (stateless). Зачем: ИИ-агент (Claude Code и совместимые) управляет Synapse целиком — выдаёт API-ключи клиентам (сервисам-источникам), регистрирует типы, ведёт правила маршрутизации, смотрит поток событий и доставок, меняет настройки — -без ручной работы в админке. Правка 2026-10-03. +без ручной работы в админке. Правка 2026-10-03; персональные ключи 2026-10-04. -## Включение +## Аутентификация: три вида доступа -1. `MCP_TOKEN=` в `.env` (и gnexus-creds). Пусто/не задано — `/mcp` - не существует: GET уходит в SPA, POST отвечает 405. -2. Перезапуск контейнера api. -3. Регистрация в Claude Code: +`/mcp` монтируется **всегда**; гейт (`McpTokenGuard`, app/mcp/server.py) +принимает два вида Bearer-ключей: + +1. **Персональный `mcp_...`** — основной путь. Каждый пользователь выпускает + себе ключ на SPA-странице **`/mcp-keys`** (или `POST /api/v1/me/mcp_tokens`, + plaintext в ответе ровно один раз; в БД — sha256-хэш + хвост). Одновременно + активно до 10 ключей. **Ключ действует ролью владельца на момент выпуска** + (снейпшот `system_role` в строке; call-home по sub без токена gnexus-auth + невозможен — после смены роли выпустите ключ заново, старый отзовите): + - роль `admin`/`superadmin` — весь каталог тулов (35); + - роль `user` — только личный набор: `me`, `my_events_list`, + `my_push_subscriptions_list`, `my_push_subscription_delete`; любой + админ-тул отвечает `{"error": 403, "detail": "…"}`. + Админ видит ключи всех на `/mcp-keys` (или `GET /api/v1/admin/mcp_tokens`) + и может отозвать любой (`POST /api/v1/admin/mcp_tokens/{id}/revoke`). + SSO-логаут MCP-токены не трогает — это самостоятельные креды, как + ключи источников. +2. **Статический `MCP_TOKEN` из `.env`** — суперадмин-бэкдор: сохраняет + совместимость с существующими сценариями агентов. Опционален (пусто — + работают только персональные ключи); держать в `.env` + gnexus-creds. + Генерация: `openssl rand -hex 32`. + +Регистрация в Claude Code: ```bash claude mcp add --transport http synapse http://localhost:8013/mcp \ - --header "Authorization: Bearer " + --header "Authorization: Bearer mcp_<персональный ключ>" ``` -Генерация токена: `openssl rand -hex 32`. Токен — как ключ прод-сервера -(внутри API полномочия MCD = superadmin), хранить в `.env` + gnexus-creds, -никогда не в репозитории. - ## Архитектура - **Тулы не дублируют логику**: сервер переиспользует admin-route функции - (`app/api/admin_routes.py`) напрямую — та же pydantic-валидация, те же коды - ошибок и тексты. Автор запросов — синтетический `superadmin` (токен и есть - доверие). + (`app/api/admin_routes.py`)/личные (`me_routes.py`) напрямую — та же + pydantic-валидация, те же коды ошибок и тексты. Автор запросов — реальный + владелец ключа (роль — снейпшот выпуска; статический токен = superadmin). +- Роль-гейт на тулах: `_admin_run` (`app/mcp/tools.py`) пропускает + admin/superadmin, для user-ключа возвращает данные `{"error": 403}`. - Ошибка тулза возвращается как данные: `{"error": 409, "detail": "…"}` — агент читает причину и может её исправить (например restore архивной записи). - Синхронная SQLAlchemy-сессия на вызов; FastMCP исполняет синхронные тулы - в threadpool. -- Гейт — чистый ASGI-класс (`McpTokenGuard`, app/mcp/server.py): проверяет - `Authorization: Bearer` на каждом запросе, 401 (`www-authenticate: Bearer`) - при несовпадении; не BaseHTTPMiddleware — SSE-стримы не ломает. -- Выключение: убрать `MCP_TOKEN` → сервер не монтируется. + в threadpool (ContextVar пользователя прокидывается в тулы). +- Гейт — чистый ASGI-класс (`McpTokenGuard`, app/mcp/server.py): резолвит + `Authorization: Bearer` (стат. токен или `mcp_*` в БД) на каждом запросе, + 401 (`www-authenticate: Bearer`) при промахе; не BaseHTTPMiddleware — + SSE-стримы не ломает. Обновляет `last_used_at` токена. -## Каталог тулов (31) +## Каталог тулов (35) | Группа | Тулы | |---|---| @@ -54,6 +70,7 @@ | Push | `push_subscriptions_list` (user_id) · `push_subscription_delete` (жёстко — устройства пользователей) | | Настройки | `settings_get` (секреты write-only: value=null, факт в set) · `settings_put` (правила docs/06: "" — не менять, null — сброс; настройка push-канала — рецепт VAPID в docs/06 «Как задать VAPID», выполняется shell'ом в контейнере) | | Контроль | `send_test_event` (source_name, subject, action, payload, priority) — полный маршрут в воркере | +| Личный (любая роль) | `me` (sub/email/роль ключа) · `my_events_list` · `my_push_subscriptions_list` · `my_push_subscription_delete` | ## Архив вместо удаления (семантика DELETE) diff --git a/docs/08-deploy-runbook.md b/docs/08-deploy-runbook.md index 7a57537..d102738 100644 --- a/docs/08-deploy-runbook.md +++ b/docs/08-deploy-runbook.md @@ -142,7 +142,7 @@ --header "Authorization: Bearer " ``` -Приёмка конвейера целиком — через MCP-тулы (docs/07-mcp.md, каталог 31 тул): +Приёмка конвейера целиком — через MCP-тулы (docs/07-mcp.md, каталог 35 тулов): `system_status` → `source_create` → `key_issue` (plaintext один раз — сразу передать владельцу в его .env/gnexus-creds) → `type_register` → `target_create` (internal_log; телеграм-цель — отложен: не создавать, #29) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4699810..33e6895 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -30,6 +30,7 @@ { to: "/targets", label: "Цели", icon: "ph-target" }, { to: "/rules", label: "Правила", icon: "ph-funnel" }, { to: "/deliveries", label: "Доставки", icon: "ph-paper-plane-tilt" }, + { to: "/mcp-keys", label: "MCP-ключи", icon: "ph-key" }, { to: "/settings", label: "Настройки", icon: "ph-gear-six" }, ]; const adminCurrents = { @@ -39,12 +40,14 @@ "/targets": "Цели", "/rules": "Правила", "/deliveries": "Доставки", + "/mcp-keys": "MCP-ключи", "/settings": "Настройки", }; const items = computed(() => profile.value?.system_role === "user" ? [ { to: "/my-events", label: "Мои события", icon: "ph-user" }, + { to: "/mcp-keys", label: "MCP-ключи", icon: "ph-key" }, { to: "/settings", label: "Настройки", icon: "ph-gear-six" }, ] : adminItems, @@ -52,7 +55,9 @@ // Префиксно: /sources/7 тоже подписывается «Источники». const current = computed(() => { const paths = - profile.value?.system_role === "user" ? { "/my-events": "Мои события" } : adminCurrents; + profile.value?.system_role === "user" + ? { "/my-events": "Мои события", "/mcp-keys": "MCP-ключи" } + : adminCurrents; return ( Object.entries(paths) .filter(([p]) => route.path === p || route.path.startsWith(p + "/")) diff --git a/frontend/src/api.js b/frontend/src/api.js index e6136e0..4222c22 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -188,6 +188,17 @@ saveSubscription: (data) => apiFetch("/api/v1/me/push/subscriptions", { method: "POST", body: JSON.stringify(data) }), removeSubscription: (id) => apiFetch(`/api/v1/me/push/subscriptions/${id}`, { method: "DELETE" }), + + // MCP-ключи: личные (любая роль) + админ-обзор всех + mcpTokens: () => apiFetch("/api/v1/me/mcp_tokens"), + createMcpToken: (data) => + apiFetch("/api/v1/me/mcp_tokens", { method: "POST", body: JSON.stringify(data) }), + revokeMcpToken: (id) => + apiFetch(`/api/v1/me/mcp_tokens/${id}/revoke`, { method: "POST" }), + adminMcpTokens: (query = {}) => + apiFetch(`/api/v1/admin/mcp_tokens?${new URLSearchParams(query)}`), + adminRevokeMcpToken: (id) => + apiFetch(`/api/v1/admin/mcp_tokens/${id}/revoke`, { method: "POST" }), }; /** Логаут: revoke всех входов пользователя (наш + на gnexus-auth). */ diff --git a/frontend/src/router.js b/frontend/src/router.js index 04e8776..f9dab9c 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -4,6 +4,7 @@ import DashboardView from "./views/DashboardView.vue"; import DeniedView from "./views/DeniedView.vue"; import MyEventsView from "./views/MyEventsView.vue"; +import McpKeysView from "./views/McpKeysView.vue"; import OauthCallbackView from "./views/OauthCallbackView.vue"; import SourcesView from "./views/SourcesView.vue"; import SourceDetailView from "./views/SourceDetailView.vue"; @@ -31,6 +32,7 @@ // Личный раздел — любая роль; админские пути — requiresAdmin (гейт ниже). // /settings — один раздел для всех: личные пуш-настройки, админу ещё конфиги. { path: "/my-events", component: MyEventsView, meta: { userOk: true } }, + { path: "/mcp-keys", component: McpKeysView, meta: { userOk: true } }, { path: "/settings", component: SettingsView, meta: { userOk: true } }, { path: "/dashboard", diff --git a/frontend/src/views/McpKeysView.vue b/frontend/src/views/McpKeysView.vue new file mode 100644 index 0000000..c8b32e7 --- /dev/null +++ b/frontend/src/views/McpKeysView.vue @@ -0,0 +1,353 @@ + + + + + + \ No newline at end of file