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,