fix(security): F08 (Astra P1) — External-Agent-API fuer reine Bearer-Clients oeffnen, get_db-TypeError fixen
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
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.)
This commit is contained in:
+48
@@ -257,6 +257,54 @@ async def get_current_user_or_bearer(
|
|||||||
return await get_current_user(request, db, redis)
|
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(
|
async def require_admin(
|
||||||
current_user: dict[str, Any] = Depends(get_current_user),
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ from fastapi.responses import StreamingResponse
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.db import get_db, set_tenant_context
|
from app.core.db import get_db, get_session_factory, set_tenant_context
|
||||||
from app.deps import get_current_user_bearer, require_permission
|
from app.deps import get_current_user_bearer, require_permission_or_bearer
|
||||||
from app.plugins.builtins.ai_assistant.schemas import (
|
from app.plugins.builtins.ai_assistant.schemas import (
|
||||||
ExternalAgentRequest,
|
ExternalAgentRequest,
|
||||||
ExternalAgentResponse,
|
ExternalAgentResponse,
|
||||||
@@ -39,7 +39,7 @@ async def _check_external_rate_limit(request: Request, tenant_id: str, token_pre
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{agent_id}/run",
|
"/{agent_id}/run",
|
||||||
dependencies=[Depends(require_permission("ai:write"))],
|
dependencies=[Depends(require_permission_or_bearer("ai:write"))],
|
||||||
)
|
)
|
||||||
async def run_agent_external(
|
async def run_agent_external(
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
@@ -123,7 +123,8 @@ async def run_agent_external(
|
|||||||
# Run the agent via streaming chat (non-streaming mode)
|
# Run the agent via streaming chat (non-streaming mode)
|
||||||
|
|
||||||
full_response = ""
|
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)
|
await set_tenant_context(stream_db, tenant_id)
|
||||||
async for chunk in stream_chat(
|
async for chunk in stream_chat(
|
||||||
stream_db,
|
stream_db,
|
||||||
@@ -155,7 +156,7 @@ async def run_agent_external(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{agent_id}/status",
|
"/{agent_id}/status",
|
||||||
dependencies=[Depends(require_permission("ai:read"))],
|
dependencies=[Depends(require_permission_or_bearer("ai:read"))],
|
||||||
)
|
)
|
||||||
async def get_agent_status_external(
|
async def get_agent_status_external(
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
@@ -218,7 +219,7 @@ async def get_agent_status_external(
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{agent_id}/stream",
|
"/{agent_id}/stream",
|
||||||
dependencies=[Depends(require_permission("ai:write"))],
|
dependencies=[Depends(require_permission_or_bearer("ai:write"))],
|
||||||
)
|
)
|
||||||
async def stream_agent_external(
|
async def stream_agent_external(
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
|
|||||||
Reference in New Issue
Block a user