diff --git a/.env.example b/.env.example index 9c612b2..c3b9f4a 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,8 @@ GAUTH_CLIENT_ID=change-me GAUTH_CLIENT_SECRET=change-me GAUTH_REDIRECT_URI=http://localhost:8134/auth/callback +# Секрет подписи webhook от gnexus-auth (создаётся в админке SSO); пусто — webhook выключен +GAUTH_WEBHOOK_SECRET= # Session cookie SESSION_SECRET=change-me-session-secret diff --git a/backend/app/auth/webhooks.py b/backend/app/auth/webhooks.py new file mode 100644 index 0000000..4ff56b3 --- /dev/null +++ b/backend/app/auth/webhooks.py @@ -0,0 +1,87 @@ +"""Webhook от gnexus-auth (ТЗ 3.16). + +SSO асинхронно доставляет подписанные события (контракт — docs/04-events-and-webhooks.md +в репозитории gnexus-auth): изменения профиля и статуса пользователя. Эндпоинт +открытый — подлинность гарантирует HMAC-SHA256-подпись (заголовки X-GNexus-*), +секрет GAUTH_WEBHOOK_SECRET задаётся при создании webhook в админке SSO. +Не задан — приём webhook выключен (503). + +Обрабатываем подмножество, значимое для gntodo: +- user.email_changed / user.profile_updated — синхронизация профиля User; +- user.blocked / user.archived / user.deleted — отзыв всех MCP-токенов + (аккаунт более недоступен в SSO — агент не должен действовать от его имени). +Ограничение: cookie-сессии stateless, серверно их не отозвать — доступ +пользователю закрывается на стороне SSO. Остальные события (включая +webhook.test и новые типы) подтверждаем 200 без действий — чтобы SSO не +гонял retry'и по незнакомому типу. +""" + +import logging + +from fastapi import APIRouter, HTTPException, Request +from gnexus_gauth.dto import WebhookEvent +from sqlalchemy import delete + +from app.auth.client import get_gauth_client +from app.config import get_settings +from app.dependencies import DbDep +from app.models import McpToken, User + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/auth", tags=["auth"]) + +# Аккаунт более недоступен в SSO — MCP-токены отзываем (агент теряет доступ). +REVOKE_EVENTS = frozenset({"user.blocked", "user.archived", "user.deleted"}) + + +@router.post("/webhook") +async def webhook(request: Request, db: DbDep) -> dict[str, bool]: + secret = get_settings().gauth_webhook_secret + if not secret: + raise HTTPException(status_code=503, detail="Webhook is not configured") + + raw = (await request.body()).decode() + try: + event = get_gauth_client().verify_and_parse_webhook(raw, dict(request.headers), secret) + except Exception as exc: # WebhookVerification/PayloadException из gnexus_gauth + logger.warning("gauth webhook отклонён: %s", exc) + raise HTTPException(status_code=400, detail="Invalid webhook") from exc + + _handle(db, event) + return {"ok": True} + + +def _handle(db: DbDep, event: WebhookEvent) -> None: + data = event.metadata + # user.id в data; для событий без data.user — target.user_id (базовый payload) + data_user = (data.get("user") or {}).get("id") + target_user = event.target_identifiers.get("user_id") + user_id = str(data_user or target_user or "") + + if event.event_type in REVOKE_EVENTS: + if user_id: + db.execute(delete(McpToken).where(McpToken.user_id == user_id)) + logger.info( + "gauth webhook %s: MCP-токены пользователя %s отозваны", event.event_type, user_id + ) + return + + user = db.get(User, user_id) if user_id else None + + if event.event_type == "user.email_changed": + email = data.get("new_email") + if user is not None and email: + user.email = str(email) + return + + if event.event_type == "user.profile_updated": + profile = data.get("profile") or {} + if user is not None: + if "locale" in profile: + user.locale = profile.get("locale") or "" + if "avatar_url" in profile: + user.avatar_url = profile.get("avatar_url") + return + + logger.info("gauth webhook %s (user=%s) — без действий", event.event_type, user_id or "?") diff --git a/backend/app/config.py b/backend/app/config.py index f552327..20973b9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,6 +19,9 @@ gauth_client_id: str = "" gauth_client_secret: str = "" gauth_redirect_uri: str = "http://localhost:8134/auth/callback" + # Секрет подписи webhook от gnexus-auth (показывается при создании webhook + # в админке SSO). Пусто — приём webhook выключен (эндпоинт отвечает 503). + gauth_webhook_secret: str = "" # Database database_url: str = "postgresql+psycopg://gntodo:gntodo@localhost:5432/gntodo" diff --git a/backend/app/main.py b/backend/app/main.py index 5919b20..4ca790b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,6 +17,7 @@ from app.api.mcp_tokens import router as mcp_tokens_router from app.api.settings import router as settings_router from app.auth.routes import router as auth_router +from app.auth.webhooks import router as auth_webhooks_router from app.config import get_settings from app.db import get_session_factory from app.models import McpToken @@ -74,6 +75,7 @@ app.add_middleware(McpAuthMiddleware) app.include_router(auth_router) +app.include_router(auth_webhooks_router) app.include_router(tasks.router) app.include_router(projects.router) app.include_router(tags.router) diff --git a/backend/app/services/users.py b/backend/app/services/users.py index 0f074bf..25813ab 100644 --- a/backend/app/services/users.py +++ b/backend/app/services/users.py @@ -33,6 +33,9 @@ user.email = email user.avatar_url = avatar_url user.locale = locale + # flush сразу: claim_* (Core UPDATE) не триггерит autoflush, и FK + # projects.user_id → users без этого падает на первом логине + db.flush() return user diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 65d6c68..8f272c2 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,5 +1,7 @@ """Smoke-тесты M0: health, защита API, redirect OAuth-флоу.""" +from typing import Any + from fastapi.testclient import TestClient from app.config import get_settings @@ -35,6 +37,53 @@ assert _safe_return_to("") == "/" +def test_callback_upserts_user_and_claims_orphans( + client: TestClient, monkeypatch: Any +) -> None: + """Регрессия 500 на первом логине: upsert_user держал INSERT pending, + а claim_orphan_data (Core UPDATE) не триггерит autoflush — FK + projects.user_id → users падала на 'Key (user_id)=(x) is not present'.""" + from types import SimpleNamespace + + from sqlalchemy import select + + from app.auth import routes as auth_routes + from app.models import Project, User + from tests.conftest import _test_session_factory + + setup = _test_session_factory() + try: + setup.add(Project(name="Наследие")) # user_id NULL — данные прежней эпохи + setup.commit() + finally: + setup.close() + + token_set = SimpleNamespace(access_token="at", refresh_token="rt") + sso_user = SimpleNamespace( + user_id="9", email="nine@example.com", avatar_url=None, profile={"locale": "ru"} + ) + stub = SimpleNamespace( + exchange_authorization_code=lambda code, state: token_set, + fetch_user=lambda token: sso_user, + ) + monkeypatch.setattr(auth_routes, "get_gauth_client", lambda: stub) + + resp = client.get( + "/auth/callback", params={"code": "c", "state": "s"}, follow_redirects=False + ) + assert resp.status_code in (302, 307) + assert resp.headers["location"] == "/" + + check = _test_session_factory() + try: + assert check.get(User, "9") is not None + assert check.scalars(select(Project).where(Project.user_id.is_(None))).all() == [] + claimed = check.scalars(select(Project).where(Project.user_id == "9")).all() + assert [p.name for p in claimed] == ["Наследие"] + finally: + check.close() + + def test_default_session_secret_rejected() -> None: """Забытый SESSION_SECRET не должен поднимать приложение молча.""" import pytest as _pytest diff --git a/backend/tests/test_auth_webhooks.py b/backend/tests/test_auth_webhooks.py new file mode 100644 index 0000000..ea35762 --- /dev/null +++ b/backend/tests/test_auth_webhooks.py @@ -0,0 +1,182 @@ +"""Webhook от gnexus-auth (ТЗ 3.16): подпись, события профиля, отзыв MCP-токенов.""" + +import hashlib +import hmac +import json +import time +from typing import Any + +from sqlalchemy import select + +from app.models import McpToken, User +from tests.conftest import _test_session_factory + +WEBHOOK_SECRET = "whsec-test" + + +def _signed_headers( + body: str, event_type: str = "webhook.test", secret: str = WEBHOOK_SECRET +) -> dict[str, str]: + ts = str(int(time.time())) + sig = hmac.new(secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest() + return { + "X-GNexus-Event-Id": "evt-1", + "X-GNexus-Event-Type": event_type, + "X-GNexus-Event-Timestamp": ts, + "X-GNexus-Signature": f"t={ts},v1={sig}", + } + + +def _enable_webhook(monkeypatch: Any) -> None: + """GAUTH_WEBHOOK_SECRET читается через get_settings в момент запроса.""" + import app.auth.webhooks as wh + + class _Settings: + gauth_webhook_secret = WEBHOOK_SECRET + + monkeypatch.setattr(wh, "get_settings", lambda: _Settings()) + + +def _post(client: Any, payload: dict, event_type: str, secret: str = WEBHOOK_SECRET) -> Any: + body = json.dumps(payload) + return client.post( + "/auth/webhook", + content=body, + headers={"Content-Type": "application/json", **_signed_headers(body, event_type, secret)}, + ) + + +def test_webhook_disabled_without_secret(client: Any, monkeypatch: Any) -> None: + # секрет не задан (дефолт Settings) — эндпоинт выключен + resp = _post(client, {"type": "webhook.test"}, "webhook.test") + assert resp.status_code == 503 + + +def test_webhook_test_event_accepted(client: Any, monkeypatch: Any) -> None: + _enable_webhook(monkeypatch) + resp = _post(client, {"type": "webhook.test", "data": {}}, "webhook.test") + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + + +def test_webhook_invalid_signature_rejected(client: Any, monkeypatch: Any) -> None: + _enable_webhook(monkeypatch) + body = json.dumps({"type": "webhook.test"}) + resp = client.post( + "/auth/webhook", + content=body, + headers={ + "Content-Type": "application/json", + **_signed_headers(body, "webhook.test", "wrong-secret"), + }, + ) + assert resp.status_code == 400 + + +def test_webhook_garbage_payload_rejected(client: Any, monkeypatch: Any) -> None: + _enable_webhook(monkeypatch) + body = "not json" + resp = client.post( + "/auth/webhook", + content=body, + headers={"Content-Type": "application/json", **_signed_headers(body)}, + ) + assert resp.status_code == 400 + + +def test_user_blocked_revokes_mcp_tokens(client: Any, monkeypatch: Any) -> None: + _enable_webhook(monkeypatch) + session = _test_session_factory() + try: + session.add(McpToken(user_id="u1", token_hash="a" * 64, label="x")) + session.add(McpToken(user_id="u2", token_hash="b" * 64, label="y")) + session.commit() + + resp = _post( + client, + {"type": "user.blocked", "data": {"user": {"id": "u1"}}}, + "user.blocked", + ) + assert resp.status_code == 200 + + remaining = session.scalars(select(McpToken).where(McpToken.user_id == "u1")).all() + assert remaining == [] + others = session.scalars(select(McpToken).where(McpToken.user_id == "u2")).all() + assert len(others) == 1 + finally: + session.close() + + +def test_user_deleted_revokes_mcp_tokens(client: Any, monkeypatch: Any) -> None: + _enable_webhook(monkeypatch) + session = _test_session_factory() + try: + session.add(McpToken(user_id="u1", token_hash="a" * 64, label="x")) + session.commit() + + # user_id в target (после удаления персональные данные не передаются) + resp = _post(client, {"type": "user.deleted", "target": {"user_id": "u1"}}, "user.deleted") + assert resp.status_code == 200 + assert session.scalars(select(McpToken).where(McpToken.user_id == "u1")).all() == [] + finally: + session.close() + + +def test_email_changed_updates_user(client: Any, monkeypatch: Any) -> None: + _enable_webhook(monkeypatch) + session = _test_session_factory() + try: + session.add(User(id="u1", email="old@example.com", avatar_url=None, locale="ru")) + session.commit() + + resp = _post( + client, + { + "type": "user.email_changed", + "data": {"user": {"id": "u1"}, "new_email": "new@example.com"}, + }, + "user.email_changed", + ) + assert resp.status_code == 200 + session.expire_all() + assert session.get(User, "u1").email == "new@example.com" + finally: + session.close() + + +def test_profile_updated_syncs_locale_and_avatar(client: Any, monkeypatch: Any) -> None: + _enable_webhook(monkeypatch) + session = _test_session_factory() + try: + session.add(User(id="u1", email="u@example.com", avatar_url=None, locale="ru")) + session.commit() + + resp = _post( + client, + { + "type": "user.profile_updated", + "data": { + "user": {"id": "u1"}, + "profile": {"locale": "uk", "avatar_url": "https://sso/avatar.jpg"}, + }, + }, + "user.profile_updated", + ) + assert resp.status_code == 200 + session.expire_all() + user = session.get(User, "u1") + assert user.locale == "uk" + assert user.avatar_url == "https://sso/avatar.jpg" + finally: + session.close() + + +def test_unknown_event_type_is_acked(client: Any, monkeypatch: Any) -> None: + """Незнакомый тип SSO подтверждаем 200 — без retry-шторма с его стороны.""" + _enable_webhook(monkeypatch) + resp = _post( + client, + {"type": "group.user_added", "data": {"user": {"id": "u1"}}}, + "group.user_added", + ) + assert resp.status_code == 200 diff --git a/docs/TZ.md b/docs/TZ.md index b423ff1..d50856e 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.34 | +| Версия ТЗ | 0.35 | | Дата | 2026-09-22 | | Статус | На обсуждении | @@ -416,6 +416,24 @@ - **Dev-режим**: SW не включён (`devOptions` выключен) — PWA проверяется на прод-сборке (`dist`), не на dev-сервере. +### 3.16. Вебхуки системы авторизации (0.35) + +- gnexus-auth асинхронно доставляет подписанные события на `POST /auth/webhook` + (контракт — docs/04-events-and-webhooks.md в репозитории gnexus-auth): HMAC-SHA256 + заголовков `X-GNexus-*`, секрет — `GAUTH_WEBHOOK_SECRET` (создаётся/ротируется в + админке SSO, `client.webhook/rotate-secret`); секрет не задан — приём выключен (503). +- Обрабатываемые события: + - `user.email_changed`, `user.profile_updated` — синхронизация профиля + `User` (email, locale, avatar_url) без повторного логина; + - `user.blocked`, `user.archived`, `user.deleted` — отзыв всех MCP-токенов + пользователя (агент теряет доступ вместе с аккаунтом). +- Незнакомые типы событий (включая `webhook.test` и будущие) подтверждаются 200 + без действий — чтобы SSO не гонял retry'и по незнакомому типу; при недействительной + подписи/битом payload — 400. +- Ограничение: cookie-сессии gntodo stateless (подписанная кука), серверно их не + отозвать — при блокировке/выходе доступ закрывается на стороне SSO; у gntodo + webhook отзывает только MCP-токены. + ## 4. Нефункциональные требования diff --git a/docs/deploy.md b/docs/deploy.md index 06e621b..9dc6550 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -44,6 +44,7 @@ | `GAUTH_BASE_URL` | `https://auth.gnexus.space` (дефолт) | | `GAUTH_CLIENT_ID` / `GAUTH_CLIENT_SECRET` | выдать/взять в панели SSO | | `GAUTH_REDIRECT_URI` | `https://<домен>/auth/callback` — **обязан совпасть** с зарегистрированным в SSO; из него же выводится разрешённый Host для `/mcp` | +| `GAUTH_WEBHOOK_SECRET` | секрет webhook из панели SSO (п. 3.1); пусто — webhook от SSO выключен (эндпоинт отвечает 503) | | `SESSION_SECRET` | вывод `openssl rand -hex 32`; **не** `change-me-*` — приложение не стартует с дефолтом | | `OLLAMA_BASE_URL` | адрес внешнего сервера Ollama, например `http://192.168.1.130:11434` | | `OLLAMA_MODEL` | `qwen3.5:2b-q4_K_M` (дефолт) | @@ -59,6 +60,17 @@ падает на этапе обмена кода. Если данных SSO нет — **остановиться и спросить пользователя**. +### 3.1. Webhook от SSO (ТЗ 3.16) + +В той же панели настроить webhook для client'а gntodo (endpoint +`https://<домен>/auth/webhook`), выбрать события профиля/статуса пользователя +(`user.email_changed`, `user.profile_updated`, `user.blocked`, `user.archived`, +`user.deleted`). Секрет подписи, показанный при создании (или +`webhook/rotate-secret`), вписать в `.env` как `GAUTH_WEBHOOK_SECRET`. +Проверка доставки — кнопка «test» в панели: gntodo ответит 200 (событие +`webhook.test` подтверждается без действий). Пустой секрет — приём webhook +выключен (503), на работу логина не влияет. + ## 4. Запуск ```bash