diff --git a/panel/backend/app/api/auth_routes.py b/panel/backend/app/api/auth_routes.py index 4e84266..69ecf63 100644 --- a/panel/backend/app/api/auth_routes.py +++ b/panel/backend/app/api/auth_routes.py @@ -103,6 +103,10 @@ if user.status in {"disabled", "blocked", "deleted"}: return JSONResponse({"detail": "User is disabled."}, status_code=403) session_id = await create_session(user) + # Успешный логин — пользователь жив и не заблокирован: снимаем флаг, + # поставленный вебхуком user.blocked (иначе его некому снять). + await db.execute("UPDATE users SET blocked = 0 WHERE user_id = ?", (user.user_id,)) + await db.commit() await db.execute("DELETE FROM oauth_states WHERE state = ?", (state,)) await db.commit() response = RedirectResponse(safe_return_to(saved["return_to"]), status_code=302) @@ -167,6 +171,7 @@ "email": session["email"], "display_name": session["display_name"], "avatar_url": session["avatar_url"], + "role": (row["system_role"] if row and row["system_role"] else None) or "user", }, "locale": override, "locale_effective": effective_locale(override, profile), diff --git a/panel/backend/app/api/mcp_tokens.py b/panel/backend/app/api/mcp_tokens.py new file mode 100644 index 0000000..ba95db2 --- /dev/null +++ b/panel/backend/app/api/mcp_tokens.py @@ -0,0 +1,192 @@ +"""Персональные MCP-ключи (канон handbook 10-platform/mcp.md, референс +gnexus-synapse): выдача, списки, ревок. + +Выпуск — только из cookie-сессии: у Bearer-админ-токена нет личности, которой +можно выдать ключ, а ключ — персональные креды. Персональный mcp_* на /mcp +принимает гард require_mcp (app/security.py), статический +GHARD_ADMIN_TOKEN остаётся супер-токеном. Plaintext существует ровно один раз +— в ответе 201 создания; в БД только sha256-хэш и хвост-хинт. +""" + +import time +from datetime import datetime, timezone + +from pydantic import BaseModel, Field + +from fastapi import APIRouter, Depends, HTTPException, Request + +from app.auth import SESSION_COOKIE, auth_enabled, get_session, get_user_row +from app.db import get_db +from app.security import ( + generate_mcp_token, + hash_key, + mcp_token_hint, + require_admin, +) + +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 _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +async def _require_session(request: Request) -> dict: + """Только cookie-сессия (как PATCH /me): Bearer-админ ключи не выдаёт.""" + if not auth_enabled(): + raise HTTPException( + status_code=401, detail="personal MCP keys require gnexus-auth sign-in" + ) + session = await get_session(request.cookies.get(SESSION_COOKIE)) + if session is None: + raise HTTPException(status_code=401, detail="not authenticated") + return session + + +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, request: Request) -> 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["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["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(), request) + + +@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"]: + now = _now_iso() + await db.execute( + "UPDATE mcp_tokens SET revoked_at = ? WHERE id = ?", (now, 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"} \ No newline at end of file diff --git a/panel/backend/app/auth.py b/panel/backend/app/auth.py index 008ced4..2dfdca8 100644 --- a/panel/backend/app/auth.py +++ b/panel/backend/app/auth.py @@ -28,6 +28,9 @@ SCOPES = ["openid", "email", "profile", "roles", "permissions"] +# Вебхуки, означающие блокировку пользователя на gnexus-auth (канон mcp.md) +BLOCK_EVENTS = {"user.blocked", "user.deleted", "user.archived"} + # Лениво собираемый синхронный SDK-контекст (httpx-клиент живёт один на процесс) _ctx: dict = {} @@ -103,25 +106,31 @@ async def upsert_user_row(user, user_id: str | None = None, email: str | None = None) -> None: """users(upsert): профиль gnexus-auth verbatim. Колонку locale НЕ трогаем — - она меняется только из настроек панели, override переживает sync профиля.""" + она меняется только из настроек панели, override переживает sync профиля. + system_role пишем только когда auth его отдал (None из вебхук-профиля — + не повод затирать снейпшот, снятый при логине).""" profile = (user.profile or {}) if hasattr(user, "profile") else (user if isinstance(user, dict) else {}) if not isinstance(profile, dict): profile = {} row_id = user_id or getattr(user, "user_id", None) row_email = email or getattr(user, "email", None) or "" + role = (getattr(user, "system_role", None) or "").strip() now = _now().isoformat() db = get_db() await db.execute( - "INSERT INTO users (user_id, email, display_name, profile, created_at, updated_at)" - " VALUES (?, ?, ?, ?, ?, ?)" + "INSERT INTO users (user_id, email, display_name, profile, system_role, created_at, updated_at)" + " VALUES (?, ?, ?, ?, ?, ?, ?)" " ON CONFLICT(user_id) DO UPDATE SET email=excluded.email," " display_name=excluded.display_name, profile=excluded.profile," + " system_role=CASE WHEN excluded.system_role != '' THEN excluded.system_role" + " ELSE users.system_role END," " updated_at=excluded.updated_at", ( row_id, row_email, _display_name_from_profile(profile) or row_email, json.dumps(profile, ensure_ascii=False), + role, now, now, ), @@ -189,7 +198,9 @@ auth.global_logout = у пользователя заканчиваются все его сессии в панели (SPA-cookie перестаёт работать, как и сессия в gauth); - user.updated может обновлять имя/аватар. + user.blocked/deleted/archived — блокировка: все персональные MCP-ключи + отзываются (канон handbook mcp.md — ключи гаснут); остальное трактуем + как user.updated (обновление имени/аватара). """ db = get_db() cutoff = _now().isoformat() @@ -197,6 +208,15 @@ await db.execute( "DELETE FROM sessions WHERE user_id = ? AND expires_at > ?", (user_id, cutoff) ) + # SSO-логаут — это выход из браузера, персональные MCP-ключи не трогаем: + # это самостоятельные креды (канон mcp.md). + elif event_type in BLOCK_EVENTS: + await db.execute("UPDATE users SET blocked = 1 WHERE user_id = ?", (user_id,)) + await db.execute( + "UPDATE mcp_tokens SET revoked_at = ?" + " WHERE user_id = ? AND revoked_at IS NULL", + (cutoff, user_id), + ) else: profile = (metadata.get("profile") or {}) if isinstance(metadata, dict) else {} display_name = profile.get("display_name") or profile.get("name") @@ -225,7 +245,8 @@ """Строка users для /me (настройки пользователя); None, если не входил.""" db = get_db() cursor = await db.execute( - "SELECT user_id, email, display_name, profile, locale FROM users WHERE user_id = ?", + "SELECT user_id, email, display_name, profile, locale, system_role, blocked" + " FROM users WHERE user_id = ?", (user_id,), ) row = await cursor.fetchone() diff --git a/panel/backend/app/db.py b/panel/backend/app/db.py index 3452a1f..4c478bd 100644 --- a/panel/backend/app/db.py +++ b/panel/backend/app/db.py @@ -21,6 +21,8 @@ await _conn.executescript(schema) # Колонки, которых нет в старых БД: CREATE TABLE IF NOT EXISTS их не добавит. await _try_alter("ALTER TABLE service_samples ADD COLUMN report TEXT") + await _try_alter("ALTER TABLE users ADD COLUMN system_role TEXT") + await _try_alter("ALTER TABLE users ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0") await _conn.commit() diff --git a/panel/backend/app/main.py b/panel/backend/app/main.py index 2df53cd..c46aacd 100644 --- a/panel/backend/app/main.py +++ b/panel/backend/app/main.py @@ -8,9 +8,9 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles -from app.security import require_admin +from app.security import require_admin, require_mcp -from app.api import auth_routes, events, ingest, servers, services, shares +from app.api import auth_routes, events, ingest, mcp_tokens, servers, services, shares from app.db import close_db, init_db from app.events import offline_loop from app.mcp import mcp, mcp_endpoint @@ -75,13 +75,15 @@ app.include_router(shares.router) app.include_router(services.router) app.include_router(events.router) +app.include_router(mcp_tokens.router) +app.include_router(mcp_tokens.admin_router) # MCP для ИИ-агентов (streamable HTTP, stateless) — POST/GET/DELETE /mcp. -# Доступ как у REST API (require_admin): Bearer-токен, если задан; -# браузерная cookie gnexus-auth тоже проходит (для MCP-клиента-агента). +# Гард require_mcp: персональный Bearer mcp_* (страница «MCP-ключи»), +# статический GHARD_ADMIN_TOKEN (супер-токен), браузерная cookie сессии. app.router.routes.append( APIRoute("/mcp", mcp_endpoint, methods=["GET", "POST", "DELETE"], - dependencies=[Depends(require_admin)]) + dependencies=[Depends(require_mcp)]) ) diff --git a/panel/backend/app/mcp.py b/panel/backend/app/mcp.py index 3e9f2ff..3fd4eec 100644 --- a/panel/backend/app/mcp.py +++ b/panel/backend/app/mcp.py @@ -98,9 +98,12 @@ ``` claude mcp add --transport http ghard https://panel.example.com/mcp ``` -Transport — streamable HTTP. Авторизация: если у панели задан -`GHARD_ADMIN_TOKEN`, добавь заголовок -`Authorization: Bearer ` в конфигурацию MCP-клиента. +Transport — streamable HTTP. Авторизация — Bearer-ключ в заголовке +`Authorization`: +- персональный токен из страницы «MCP-ключи» веб-интерфейса панели + (`Authorization: Bearer mcp_…`) — ключ принадлежит вашему пользователю, + отзыв виден на той же странице; +- `GHARD_ADMIN_TOKEN` — супер-токен администратора, действует как раньше. """ diff --git a/panel/backend/app/schema.sql b/panel/backend/app/schema.sql index c947e22..84bdc76 100644 --- a/panel/backend/app/schema.sql +++ b/panel/backend/app/schema.sql @@ -128,6 +128,26 @@ display_name TEXT NOT NULL DEFAULT '', profile TEXT NOT NULL DEFAULT '{}', -- JSON профиля gnexus-auth verbatim locale TEXT, -- override 'en'|'uk'|'ru'; NULL = авто + system_role TEXT, -- снейпшот system_role gnexus-auth (обещали при логине) + blocked INTEGER NOT NULL DEFAULT 0, -- вебхук user.blocked/deleted/archived created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); + +-- Персональные MCP-ключи (handbook 10-platform/mcp.md, канон = gnexus-synapse): +-- plaintext показывается один раз, в БД — sha256-хэш (unique) + хвост-хинт; +-- снейпшот роли владельца на момент выпуска; вместо архива — ревок. +CREATE TABLE IF NOT EXISTS mcp_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, -- sub gnexus-auth (выдаёт сам себе) + name TEXT NOT NULL DEFAULT '', -- «агент Navi rei» + user_email TEXT NOT NULL DEFAULT '', -- снейпшот для списков + system_role TEXT NOT NULL DEFAULT 'user',-- снейпшот роли выпуска + token_hash TEXT NOT NULL UNIQUE, -- sha256 plaintext-ключа + token_hint TEXT NOT NULL DEFAULT '', -- хвост 8 символов для опознания в списке + created_at TEXT NOT NULL, + last_used_at TEXT, + revoked_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_mcp_tokens_user ON mcp_tokens(user_id); diff --git a/panel/backend/app/security.py b/panel/backend/app/security.py index ff37534..7a3f9a3 100644 --- a/panel/backend/app/security.py +++ b/panel/backend/app/security.py @@ -14,6 +14,17 @@ return "ghm_" + secrets.token_urlsafe(24) +def generate_mcp_token() -> str: + """Plaintext персонального MCP-ключа (канон handbook mcp.md, префикс mcp_). + Показывается один раз на странице «MCP-ключи»; в БД — хэш hash_key().""" + return "mcp_" + secrets.token_urlsafe(24) + + +def mcp_token_hint(token: str) -> str: + """Хвост для опознания ключа в списке (plaintext никогда не возвращается).""" + return token[-8:] + + def hash_key(key: str) -> str: return hashlib.sha256(key.encode()).hexdigest() @@ -51,4 +62,70 @@ raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid admin token", - ) \ No newline at end of file + ) + + +def _mcp_401() -> HTTPException: + # Причина (отозван / заблокирован / неверный) не раскрывается — канон mcp.md + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid MCP token", + headers={"www-authenticate": "Bearer"}, + ) + + +async def require_mcp( + request: Request, + creds: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> None: + """Гард MCP (/mcp): персональный mcp_* ключ | статический админ-токен | cookie. + + Порядок: + 1. Bearer mcp_… — hash-lookup в mcp_tokens: отозванный или ключ владельца + с users.blocked → тот же 401 (канон mcp.md); валидный → last_used_at. + 2. Bearer GHARD_ADMIN_TOKEN — статический супер-токен (совместимость). + 3. Cookie-сессия gnexus-auth — как в require_admin. + 4. auth выключен и admin_token пуст → открытый режим. + + Это dependency, а не ASGI-гард как в Synapse: /mcp здесь один APIRoute, + dependency решает то же самое без middleware-обёртки. + """ + settings = get_settings() + bearer = creds.credentials if creds is not None and creds.scheme.lower() == "bearer" else "" + if bearer.startswith("mcp_"): + from app.auth import _now, get_user_row + from app.db import get_db + + db = get_db() + cursor = await db.execute( + "SELECT id, user_id, revoked_at FROM mcp_tokens WHERE token_hash = ?", + (hash_key(bearer),), + ) + row = await cursor.fetchone() + if row is None or row["revoked_at"]: + raise _mcp_401() + user = await get_user_row(row["user_id"]) + if user and user["blocked"]: + raise _mcp_401() + await db.execute( + "UPDATE mcp_tokens SET last_used_at = ? WHERE id = ?", + (_now().isoformat(), row["id"]), + ) + await db.commit() + request.state.mcp_token = dict(row) + return + if settings.admin_token and bearer and bearer == settings.admin_token: + request.state.mcp_token = None + return + if settings.auth_client_id: + from app.auth import SESSION_COOKIE, get_session + + session = await get_session(request.cookies.get(SESSION_COOKIE)) + if session is None: + raise _mcp_401() + request.state.mcp_token = None + return + if not settings.admin_token: + request.state.mcp_token = None + return + raise _mcp_401() \ No newline at end of file