mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-04 20:08:40 +00:00
* feat(authz): enforce model authorization at Gateway routes and runtime (#4063 Phase 3) Phase 3 / Models — the first of three resource-type PRs (Models, Skills, Sandbox). The RBAC provider already maps "model" → config key "models" (rbac.py _RESOURCE_POLICY_KEYS), so no schema change is needed. Gateway route layer (mirrors Phase 2A): - resolve_model_authorization() in authz.py returns (provider, principal), reusing _get_cached_route_provider and build_principal_from_context, including the INTERNAL_SYSTEM_ROLE → None pop for internal callers. - list_models filters via provider.filter_resources(principal, "model", names). - get_model checks provider.authorize("model", "use"). Deny → 403 (not 404, since the model exists but the role lacks permission). Runtime resolution layer (mirrors Phase 1B): - _authorize_model_name() in agent.py runs after _resolve_model_name. On deny, falls back to the first allowed model (RFC §9: graceful, not crash). All models denied + fail_closed → ValueError (matches existing contract). authorization.enabled: false is a complete no-op on both layers. Anonymous requests (user=None) bypass filtering. 18 new tests + 314 existing tests pass. * fix(authz): enforce model:use on the embedded DeerFlowClient path (Phase 3 follow-up) Round 4 review (willem-bd): _authorize_model_name only covered the Gateway runtime path (_make_lead_agent). The parallel lead-agent construction path DeerFlowClient._ensure_agent (client.py) filtered tools but not the model, so a library/embedded consumer with role-scoped model policies could run a model the role is denied model:use for. - Insert _authorize_model_name in _ensure_agent, mirroring _make_lead_agent. - Resolve None default to the first configured model before the gate so the implicit default (create_chat_model(name=None)) is also authorized. - Update test_authorization_filters_framework_tools_and_reuses_provider: the stub provider now returns an allow decision for model:use (checked during assembly) and patches resolve_authorization_provider in the agent namespace. - Add 3 DeerFlowClient._ensure_agent path tests (real-path fallback, None-default resolution, disabled no-op); 24 tests total. * docs(authz): document get_model provider-unavailable fail-open path + test zhfeng review (round 5): get_model's docstring only mentioned the deny→403 path, not the provider-resolution-error + fail-open path (which allows the request, mirroring list_models's documented fail-open semantics). The behavior itself is correct and symmetric with list_models, but it was undocumented and the _AuthorizationUnavailable path had no test coverage. - Extend get_model docstring to state the provider-error fail-closed/fail-open outcome, matching list_models's wording. - Add test_get_model_provider_unavailable_fail_closed_vs_open exercising the _AuthorizationUnavailable path (provider cannot be resolved at all), pinning fail-closed→403 / fail-open→200. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
206 lines
7.6 KiB
Python
206 lines
7.6 KiB
Python
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.gateway.authz import (
|
|
_AuthorizationUnavailable,
|
|
_is_internal_caller,
|
|
resolve_model_authorization,
|
|
)
|
|
from app.gateway.deps import get_config, get_optional_user_from_request
|
|
from deerflow.authz.provider import AuthzDecision, AuthzRequest
|
|
from deerflow.config.app_config import AppConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api", tags=["models"])
|
|
|
|
|
|
class ModelResponse(BaseModel):
|
|
"""Response model for model information."""
|
|
|
|
name: str = Field(..., description="Unique identifier for the model")
|
|
model: str = Field(..., description="Actual provider model identifier")
|
|
display_name: str | None = Field(None, description="Human-readable name")
|
|
description: str | None = Field(None, description="Model description")
|
|
supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode")
|
|
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort")
|
|
|
|
|
|
class TokenUsageResponse(BaseModel):
|
|
"""Token usage display configuration."""
|
|
|
|
enabled: bool = Field(default=False, description="Whether token usage display is enabled")
|
|
|
|
|
|
class ModelsListResponse(BaseModel):
|
|
"""Response model for listing all models."""
|
|
|
|
models: list[ModelResponse]
|
|
token_usage: TokenUsageResponse
|
|
|
|
|
|
@router.get(
|
|
"/models",
|
|
response_model=ModelsListResponse,
|
|
summary="List All Models",
|
|
description="Retrieve a list of all available AI models configured in the system.",
|
|
)
|
|
async def list_models(
|
|
request: Request,
|
|
config: AppConfig = Depends(get_config),
|
|
) -> ModelsListResponse:
|
|
"""List all available models from configuration.
|
|
|
|
Returns model information suitable for frontend display,
|
|
excluding sensitive fields like API keys and internal configuration.
|
|
|
|
When ``authorization.enabled`` is true, only models the caller's role may
|
|
``list`` are returned (filtered via ``provider.filter_resources``). A
|
|
provider error yields an empty list (fail-closed) or all models (fail-open).
|
|
|
|
Returns:
|
|
A list of all configured models with their metadata and token usage display settings.
|
|
|
|
Example Response:
|
|
```json
|
|
{
|
|
"models": [
|
|
{
|
|
"name": "gpt-4",
|
|
"model": "gpt-4",
|
|
"display_name": "GPT-4",
|
|
"description": "OpenAI GPT-4 model",
|
|
"supports_thinking": false,
|
|
"supports_reasoning_effort": false
|
|
},
|
|
{
|
|
"name": "claude-3-opus",
|
|
"model": "claude-3-opus",
|
|
"display_name": "Claude 3 Opus",
|
|
"description": "Anthropic Claude 3 Opus model",
|
|
"supports_thinking": true,
|
|
"supports_reasoning_effort": false
|
|
}
|
|
],
|
|
"token_usage": {
|
|
"enabled": true
|
|
}
|
|
}
|
|
```
|
|
"""
|
|
visible_models = config.models
|
|
fail_closed = config.authorization.fail_closed
|
|
|
|
user = await get_optional_user_from_request(request)
|
|
if user is not None:
|
|
try:
|
|
provider, principal = resolve_model_authorization(user, is_internal=_is_internal_caller(request, user))
|
|
except _AuthorizationUnavailable as exc:
|
|
if exc.fail_closed:
|
|
visible_models = []
|
|
else:
|
|
if provider is not None and principal is not None:
|
|
try:
|
|
allowed_names = provider.filter_resources(principal, "model", [m.name for m in config.models])
|
|
if not isinstance(allowed_names, list) or any(not isinstance(n, str) for n in allowed_names):
|
|
raise TypeError("AuthorizationProvider.filter_resources must return list[str]")
|
|
allowed_set = set(allowed_names)
|
|
visible_models = [m for m in config.models if m.name in allowed_set]
|
|
except Exception:
|
|
logger.warning("Authorization provider failed while filtering models", exc_info=True)
|
|
visible_models = [] if fail_closed else config.models
|
|
|
|
models = [
|
|
ModelResponse(
|
|
name=model.name,
|
|
model=model.model,
|
|
display_name=model.display_name,
|
|
description=model.description,
|
|
supports_thinking=model.supports_thinking,
|
|
supports_reasoning_effort=model.supports_reasoning_effort,
|
|
)
|
|
for model in visible_models
|
|
]
|
|
return ModelsListResponse(
|
|
models=models,
|
|
token_usage=TokenUsageResponse(enabled=config.token_usage.enabled),
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/models/{model_name}",
|
|
response_model=ModelResponse,
|
|
summary="Get Model Details",
|
|
description="Retrieve detailed information about a specific AI model by its name.",
|
|
)
|
|
async def get_model(
|
|
model_name: str,
|
|
request: Request,
|
|
config: AppConfig = Depends(get_config),
|
|
) -> ModelResponse:
|
|
"""Get a specific model by name.
|
|
|
|
Args:
|
|
model_name: The unique name of the model to retrieve.
|
|
|
|
Returns:
|
|
Model information if found.
|
|
|
|
Raises:
|
|
HTTPException: 404 if model not found; 403 if the caller's role may not
|
|
``use`` the model (only when ``authorization.enabled`` is true). A
|
|
provider resolution error yields 403 (fail-closed) or allows the request
|
|
(fail-open), mirroring ``list_models``'s provider-error semantics.
|
|
|
|
Example Response:
|
|
```json
|
|
{
|
|
"name": "gpt-4",
|
|
"display_name": "GPT-4",
|
|
"description": "OpenAI GPT-4 model",
|
|
"supports_thinking": false
|
|
}
|
|
```
|
|
"""
|
|
model = config.get_model_config(model_name)
|
|
if model is None:
|
|
raise HTTPException(status_code=404, detail=f"Model '{model_name}' not found")
|
|
|
|
# Phase 3: enforce model:use authorization (deny → 403, not 404, since the
|
|
# model exists but the role lacks permission to use it).
|
|
fail_closed = config.authorization.fail_closed
|
|
user = await get_optional_user_from_request(request)
|
|
if user is not None:
|
|
try:
|
|
provider, principal = resolve_model_authorization(user, is_internal=_is_internal_caller(request, user))
|
|
except _AuthorizationUnavailable:
|
|
if fail_closed:
|
|
raise HTTPException(status_code=403, detail=f"Model '{model_name}' is not available for your role")
|
|
else:
|
|
if provider is not None and principal is not None:
|
|
try:
|
|
decision = provider.authorize(AuthzRequest(principal=principal, resource="model", action="use", target=model_name))
|
|
if not isinstance(decision, AuthzDecision):
|
|
raise TypeError("AuthorizationProvider.authorize must return AuthzDecision")
|
|
allowed = decision.allow
|
|
except Exception:
|
|
logger.warning(
|
|
"Authorization provider failed while checking model:use for %s",
|
|
model_name,
|
|
exc_info=True,
|
|
)
|
|
allowed = not fail_closed
|
|
if not allowed:
|
|
raise HTTPException(status_code=403, detail=f"Model '{model_name}' is not available for your role")
|
|
|
|
return ModelResponse(
|
|
name=model.name,
|
|
model=model.model,
|
|
display_name=model.display_name,
|
|
description=model.description,
|
|
supports_thinking=model.supports_thinking,
|
|
supports_reasoning_effort=model.supports_reasoning_effort,
|
|
)
|