diff --git a/backend/app/actor.py b/backend/app/actor.py new file mode 100644 index 0000000..22a2b3e --- /dev/null +++ b/backend/app/actor.py @@ -0,0 +1,88 @@ +"""Актор действия: кто и через что сделал (ТЗ 3.20). + +Токен MCP — принципал владельца, поэтому по умолчанию действие с Bearer-токеном +считается **агентским**: иначе отличить агента от браузерного расширения нечем. +Объявить действие «от имени владельца» можно флагом `is_user` (MCP-инструменты) +или заголовком `X-Actor: user` (REST): актор станет `user`, но канал (`via`) +сохранится — в журнале видно, что действие заявлено владельцем и пришло от агента. + +Это не разграничение прав: держатель токена и так действует как владелец. Актор +нужен, чтобы запись в журнале отвечала на вопрос «кто это сделал» (ТЗ 3.20, 5). +""" + +from dataclasses import dataclass +from typing import Any + +ACTOR_USER = "user" +ACTOR_AGENT = "agent" + +VIA_UI = "ui" +VIA_API = "api" +VIA_MCP = "mcp" + +# Заголовок REST, которым клиент объявляет действие от имени владельца +# (браузерное расширение — руки владельца, его захваты не агентские, ТЗ 3.18) +ACTOR_HEADER = "x-actor" +_DECLARED_VALUES = {"user", "me"} + + +@dataclass(frozen=True) +class Actor: + """Кто выполняет действие: владелец или ИИ-агент, и через какой канал.""" + + kind: str = ACTOR_USER + # Подпись для журнала: имя владельца или label токена (label может быть пустым — + # тогда интерфейс подставит своё «агент», локализованное) + name: str = "" + via: str = VIA_UI + # Токен, которым пришёл агент: сравнивать аренду можно только по id — label не уникален + token_id: int | None = None + # Имя владельца: чем подписать действие, если агент объявил его своим (is_user) + owner_name: str = "" + + @property + def is_agent(self) -> bool: + return self.kind == ACTOR_AGENT + + def declaring_user(self) -> "Actor": + """Действие, объявленное от имени владельца: подпись — его имя, канал тот же.""" + if self.kind == ACTOR_USER: + return self + return Actor( + kind=ACTOR_USER, + name=self.owner_name, + via=self.via, + token_id=self.token_id, + owner_name=self.owner_name, + ) + + +def agent_actor(label: str, token_id: int | None, owner_name: str, via: str) -> Actor: + """Актор по токену: подпись — label токена («Нави»), а не имя владельца.""" + return Actor( + kind=ACTOR_AGENT, + name=label.strip(), + via=via, + token_id=token_id, + owner_name=owner_name, + ) + + +def actor_from_request(request: Any) -> Actor: + """Актор, положенный авторизацией в `request.state.gntodo_actor` (ТЗ 3.20). + + Сведений нет — считаем действие владельца: так ведут себя тесты, подменяющие + авторизацию через dependency_overrides (и любой запрос мимо обеих схем входа, + а их всего две: сессия SSO и Bearer). + """ + actor = getattr(getattr(request, "state", None), "gntodo_actor", None) + return actor if isinstance(actor, Actor) else Actor() + + +def declares_user(headers: Any) -> bool: + """Объявлено ли действие владельца заголовком REST (`X-Actor: user`).""" + try: + value = headers.get(ACTOR_HEADER, "") + except AttributeError: # нет заголовков — например, синтетический запрос в тестах + return False + return str(value).strip().lower() in _DECLARED_VALUES diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index a7bc315..b922ed1 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -1,4 +1,4 @@ -"""Общие зависимости FastAPI: авторизация и сессия БД.""" +"""Общие зависимости FastAPI: авторизация, актор действия и сессия БД.""" import hashlib from typing import Annotated, cast @@ -7,8 +7,9 @@ from sqlalchemy import select from sqlalchemy.orm import Session +from app.actor import VIA_API, VIA_UI, Actor, actor_from_request, agent_actor, declares_user from app.db import get_db -from app.models import McpToken +from app.models import McpToken, User DbDep = Annotated[Session, Depends(get_db)] @@ -18,30 +19,66 @@ заголовок `Authorization: Bearer ` → user_id владельца. Токен в БД хранится только как sha256-хэш (как в McpAuthMiddleware для /mcp). + Рядом с user_id кладём актора (ТЗ 3.20): по токену действие считается агентским, + пока клиент не объявил `X-Actor: user` — см. actor_of. """ auth = request.headers.get("authorization", "") token = auth.removeprefix("Bearer ").strip() if not token: return None token_hash = hashlib.sha256(token.encode()).hexdigest() - user_id = db.scalar(select(McpToken.user_id).where(McpToken.token_hash == token_hash)) - if not user_id: + # OUTER: строки users может не быть (тесты, «бесхозные» пользователи) — тогда + # подпись владельца пуста, но токен обязан работать. + row = db.execute( + select(McpToken.id, McpToken.label, McpToken.user_id, User.name) + .select_from(McpToken) + .outerjoin(User, User.id == McpToken.user_id) + .where(McpToken.token_hash == token_hash) + ).first() + if row is None: return None + request.state.gntodo_actor = agent_actor(row.label or "", row.id, row.name or "", VIA_API) # Минимальный пользователь: API/сервисы читают только user_id (ТЗ 1.2). - return {"user_id": user_id} + return {"user_id": row.user_id} def require_user(request: Request, db: DbDep) -> dict[str, str]: """Защита API: сессия SSO или Bearer MCP-токен (расширение, ИИ-агенты).""" - user = request.session.get("user") or user_from_bearer(request, db) - if not user: + user = request.session.get("user") + if user: + # Сессия SSO — руки владельца (ТЗ 3.20) + request.state.gntodo_actor = Actor(name=str(user.get("name") or ""), via=VIA_UI) + return cast(dict[str, str], user) + bearer_user = user_from_bearer(request, db) + if not bearer_user: raise HTTPException(status_code=401, detail="Not authenticated") - return cast(dict[str, str], user) + return bearer_user UserDep = Annotated[dict[str, str], Depends(require_user)] +def actor_of(request: Request) -> Actor: + """Актор действия этого запроса (ТЗ 3.20). + + Заголовок `X-Actor: user` (REST) объявляет действие владельца: актор станет + `user` с его именем, но канал (`api`) сохранится — в журнале видно, что + действие заявлено владельцем и пришло от клиента с токеном. + """ + actor = actor_from_request(request) + if actor.is_agent and declares_user(request.headers): + return actor.declaring_user() + return actor + + +def require_actor(request: Request, _user: UserDep) -> Actor: + """Актор запроса — после require_user: тот уже заполнил request.state.""" + return actor_of(request) + + +ActorDep = Annotated[Actor, Depends(require_actor)] + + def require_user_id(user: UserDep) -> str: """user_id текущего пользователя — ключ изоляции всех данных (ТЗ 1.2). diff --git a/backend/app/main.py b/backend/app/main.py index 7c75ab9..5c1f897 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,6 +13,7 @@ from starlette.responses import JSONResponse, Response from app import mcp_server +from app.actor import VIA_MCP, agent_actor from app.api import attachments, events, garden, projects, tags, tasks, xp from app.api.mcp_tokens import router as mcp_tokens_router from app.api.repo_readme import router as repo_readme_router @@ -21,7 +22,7 @@ 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 +from app.models import McpToken, User @asynccontextmanager @@ -50,8 +51,10 @@ class McpAuthMiddleware(BaseHTTPMiddleware): """Bearer-токен /mcp → user_id владельца (per-user токены, ТЗ 3.10). - Токен хранится в БД только как sha256-хэш (mcp_tokens); найденный user_id - кладётся в request.state — тулы достают его через Context. + Токен хранится в БД только как sha256-хэш (mcp_tokens); найденные user_id и + актор (ТЗ 3.20) кладутся в request.state — тулы достают их через Context. + Актор здесь всегда агентский: объявить действие владельца тул может только + своим аргументом is_user, а не заголовком запроса. """ async def dispatch( @@ -64,8 +67,16 @@ if token: token_hash = hashlib.sha256(token.encode()).hexdigest() with get_session_factory()() as db: - user_id = db.scalar( - select(McpToken.user_id).where(McpToken.token_hash == token_hash) + row = db.execute( + select(McpToken.id, McpToken.label, McpToken.user_id, User.name) + .select_from(McpToken) + .outerjoin(User, User.id == McpToken.user_id) + .where(McpToken.token_hash == token_hash) + ).first() + if row is not None: + user_id = row.user_id + request.state.gntodo_actor = agent_actor( + row.label or "", row.id, row.name or "", VIA_MCP ) if not user_id: return JSONResponse({"detail": "Not authenticated"}, status_code=401)