From 001e4b415f9500f10ffc3ff6570efa6f6bc123a5 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Fri, 18 Sep 2026 11:34:18 +0200 Subject: [PATCH] =?UTF-8?q?fix(security):=20F08=20(Astra=20P1)=20=E2=80=94?= =?UTF-8?q?=20External-Agent-API=20fuer=20reine=20Bearer-Clients=20oeffnen?= =?UTF-8?q?,=20get=5Fdb-TypeError=20fixen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vorher: Alle drei External-Endpoints hingen an require_permission, das an der Session-Cookie-Auth haengt — reine Bearer-Clients (n8n, Skripte, externe Systeme) erhielten 401, bevor die Bearer-Verifikation im Handler je erreicht wurde (Astra-Repro: Statusabfrage mit nur Bearer-Header -> 401). Zusaetzlich: async with get_db() — get_db() ist ein FastAPI-AsyncGenerator, KEIN Contextmanager -> TypeError im /run-Pfad. Fix: - Neue Dependency require_permission_or_bearer (deps.py): akzeptiert Session-Cookie UND Bearer-Token via get_current_user_or_bearer und prueft dieselben effektiven Rechte — Token-Scopes bleiben Obergrenze (F10-Semantik: User-Rechte UND Scope muessen beide gewaehren) - external_api.py: alle 3 Endpunkte (run/status/stream) auf die neue Dependency umgestellt - /run-Pfad: get_db() -> get_session_factory() (Session-Factory wie alle anderen self-managed-Session-Codepfade) Abnahme (Astra): Gueltiger Bearer ohne Cookie funktioniert fuer Status, Run und Stream; ungueltige Tokens werden abgewiesen — die Permission-Pruefung laeuft identisch fuer beide Auth-Pfade. Verifikation: test_s1_security_guards + test_agent_loop 36/36, Syntax + ruff clean. (Live-Bearer-Verifikation folgt mit dem naechsten Deploy.) --- app/deps.py | 48 +++++++++++++++++++ .../builtins/ai_assistant/external_api.py | 13 ++--- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/app/deps.py b/app/deps.py index db05fbc..0c15981 100644 --- a/app/deps.py +++ b/app/deps.py @@ -257,6 +257,54 @@ async def get_current_user_or_bearer( return await get_current_user(request, db, redis) +def require_permission_or_bearer(permission: str): + """F08 (Astra P1): permission dependency for routes that serve BOTH + session-cookie clients (SPA) and pure Bearer API clients. + + ``require_permission`` resolves via ``get_current_user`` (session + cookie only) — a Bearer client fails with 401 before the route's own + Bearer verification is ever reached. This dependency accepts either + auth path and enforces the SAME effective permission: + + - session users: normal permission check + - Bearer tokens: token scopes are an UPPER BOUND (F10) — the user's + own permissions must grant the permission AND the scope must match + """ + + async def _check( + current_user: dict[str, Any] = Depends(get_current_user_or_bearer), + ) -> dict[str, Any]: + token_scopes = current_user.get("_token_scopes") + if token_scopes is not None: + from app.core.permissions import _permission_matches_any + + if not _permission_matches_any(set(token_scopes), permission): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "detail": f"Token scope '{permission}' required", + "code": "insufficient_scope", + }, + ) + # fall through — user permissions apply too (F10 semantics) + + if current_user.get("is_system_admin"): + return current_user + from app.core.permissions import check_permission + + if check_permission(current_user, permission): + return current_user + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "detail": f"Permission '{permission}' required", + "code": "forbidden", + }, + ) + + return _check + + async def require_admin( current_user: dict[str, Any] = Depends(get_current_user), ) -> dict[str, Any]: diff --git a/app/plugins/builtins/ai_assistant/external_api.py b/app/plugins/builtins/ai_assistant/external_api.py index c435130..ff97a9f 100644 --- a/app/plugins/builtins/ai_assistant/external_api.py +++ b/app/plugins/builtins/ai_assistant/external_api.py @@ -16,8 +16,8 @@ from fastapi.responses import StreamingResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.core.db import get_db, set_tenant_context -from app.deps import get_current_user_bearer, require_permission +from app.core.db import get_db, get_session_factory, set_tenant_context +from app.deps import get_current_user_bearer, require_permission_or_bearer from app.plugins.builtins.ai_assistant.schemas import ( ExternalAgentRequest, ExternalAgentResponse, @@ -39,7 +39,7 @@ async def _check_external_rate_limit(request: Request, tenant_id: str, token_pre @router.post( "/{agent_id}/run", - dependencies=[Depends(require_permission("ai:write"))], + dependencies=[Depends(require_permission_or_bearer("ai:write"))], ) async def run_agent_external( agent_id: str, @@ -123,7 +123,8 @@ async def run_agent_external( # Run the agent via streaming chat (non-streaming mode) full_response = "" - async with get_db() as stream_db: + _factory = get_session_factory() + async with _factory() as stream_db: await set_tenant_context(stream_db, tenant_id) async for chunk in stream_chat( stream_db, @@ -155,7 +156,7 @@ async def run_agent_external( @router.get( "/{agent_id}/status", - dependencies=[Depends(require_permission("ai:read"))], + dependencies=[Depends(require_permission_or_bearer("ai:read"))], ) async def get_agent_status_external( agent_id: str, @@ -218,7 +219,7 @@ async def get_agent_status_external( @router.post( "/{agent_id}/stream", - dependencies=[Depends(require_permission("ai:write"))], + dependencies=[Depends(require_permission_or_bearer("ai:write"))], ) async def stream_agent_external( agent_id: str,