deer-flow/backend/tests/test_models_authorization.py
hataa 540940bac1
feat(authz): enforce model authorization at Gateway routes and runtime (#4063 Phase 3) (#4540)
* 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>
2026-08-01 22:34:11 +08:00

725 lines
29 KiB
Python

"""Phase 3 model-level authorization tests.
Covers two enforcement layers:
- Gateway routes (``list_models``, ``get_model``) — request-scoped Principal,
mirrors Phase 2A's ``resolve_route_permissions``.
- Runtime model resolution (``_authorize_model_name``) — context-scoped
Principal with graceful fallback, mirrors Phase 1B's ``apply_tool_authorization``.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway.routers import models as models_router
from deerflow.authz.provider import AuthzDecision, AuthzReason
from deerflow.authz.rbac import RbacAuthorizationProvider
from deerflow.config.app_config import AppConfig
from deerflow.config.authorization_config import AuthorizationConfig
from deerflow.config.model_config import ModelConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.config.token_usage_config import TokenUsageConfig
# ── Helpers ────────────────────────────────────────────────────────────
def _user(**overrides):
values = {
"id": "user-123",
"system_role": "user",
"oauth_provider": "github",
"oauth_id": "oauth-456",
}
values.update(overrides)
return SimpleNamespace(**values)
def _make_app_config(model_names: list[str]) -> AppConfig:
"""Build a minimal AppConfig with the given model names."""
return AppConfig(
models=[ModelConfig(name=n, model=n, use="langchain_openai:ChatOpenAI") for n in model_names],
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"),
token_usage=TokenUsageConfig(enabled=False),
authorization=AuthorizationConfig(),
)
def _enable_authorization(monkeypatch, provider, *, fail_closed: bool = True, default_role: str = "user") -> None:
config = AuthorizationConfig(
enabled=True,
fail_closed=fail_closed,
default_role=default_role,
)
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: config)
monkeypatch.setattr("app.gateway.authz._get_cached_route_provider", lambda c: provider)
def _make_models_app(app_config: AppConfig) -> FastAPI:
"""Build a FastAPI app with the models router and a pinned config."""
app = FastAPI()
app.include_router(models_router.router)
# Pin the config dependency so routes use our test AppConfig.
app.dependency_overrides[models_router.get_config] = lambda: app_config
return app
class _RecordingProvider:
"""Provider that records all requests and can deny/error specific targets."""
name = "recording"
def __init__(self, *, denied: set[str] | None = None, errors: set[str] | None = None) -> None:
self.denied = denied or set()
self.errors = errors or set()
self.authorize_requests: list = []
self.filter_requests: list = []
def authorize(self, request):
self.authorize_requests.append(request)
if request.target in self.errors:
raise RuntimeError(f"provider failed for {request.target}")
allowed = request.target not in self.denied
return AuthzDecision(
allow=allowed,
reasons=[AuthzReason(code="authz.allowed" if allowed else "authz.denied")],
)
async def aauthorize(self, request):
return self.authorize(request)
def filter_resources(self, principal, resource_type, candidates):
self.filter_requests.append((resource_type, list(candidates)))
if resource_type in self.errors:
raise RuntimeError(f"provider failed for {resource_type}")
return [c for c in candidates if c not in self.denied]
# ── list_models tests ──────────────────────────────────────────────────
def test_list_models_disabled_returns_all(monkeypatch):
"""When authorization is disabled, all models are visible."""
config = AuthorizationConfig(enabled=False)
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: config)
cached = AsyncMock(side_effect=AssertionError("disabled must not resolve provider"))
monkeypatch.setattr("app.gateway.authz._get_cached_route_provider", cached)
app_config = _make_app_config(["gpt-4", "claude-3"])
monkeypatch.setattr(
"app.gateway.routers.models.get_optional_user_from_request",
AsyncMock(return_value=_user()),
)
with TestClient(_make_models_app(app_config)) as client:
response = client.get("/api/models")
assert response.status_code == 200
names = [m["name"] for m in response.json()["models"]]
assert names == ["gpt-4", "claude-3"]
cached.assert_not_called()
def test_list_models_anonymous_user_returns_all(monkeypatch):
"""Anonymous requests (user=None) are not filtered."""
provider = _RecordingProvider()
_enable_authorization(monkeypatch, provider)
app_config = _make_app_config(["gpt-4", "claude-3"])
monkeypatch.setattr(
"app.gateway.routers.models.get_optional_user_from_request",
AsyncMock(return_value=None),
)
with TestClient(_make_models_app(app_config)) as client:
response = client.get("/api/models")
assert response.status_code == 200
names = [m["name"] for m in response.json()["models"]]
assert names == ["gpt-4", "claude-3"]
assert provider.filter_requests == []
def test_list_models_rbac_filters_by_allow(monkeypatch):
"""Role with allowlist sees only allowed models."""
provider = RbacAuthorizationProvider(
roles={"user": {"models": {"allow": ["gpt-4"]}}},
)
_enable_authorization(monkeypatch, provider)
app_config = _make_app_config(["gpt-4", "claude-3", "llama-3"])
monkeypatch.setattr(
"app.gateway.routers.models.get_optional_user_from_request",
AsyncMock(return_value=_user()),
)
with TestClient(_make_models_app(app_config)) as client:
response = client.get("/api/models")
assert response.status_code == 200
names = [m["name"] for m in response.json()["models"]]
assert names == ["gpt-4"]
def test_list_models_rbac_filters_by_deny(monkeypatch):
"""Role with deny hides denied models."""
provider = RbacAuthorizationProvider(
roles={"user": {"models": {"allow": "*", "deny": ["claude-3"]}}},
)
_enable_authorization(monkeypatch, provider)
app_config = _make_app_config(["gpt-4", "claude-3", "llama-3"])
monkeypatch.setattr(
"app.gateway.routers.models.get_optional_user_from_request",
AsyncMock(return_value=_user()),
)
with TestClient(_make_models_app(app_config)) as client:
response = client.get("/api/models")
assert response.status_code == 200
names = [m["name"] for m in response.json()["models"]]
assert names == ["gpt-4", "llama-3"]
def test_list_models_wildcard_returns_all(monkeypatch):
"""Role with allow: '*' sees all models."""
provider = RbacAuthorizationProvider(
roles={"user": {"models": {"allow": "*"}}},
)
_enable_authorization(monkeypatch, provider)
app_config = _make_app_config(["gpt-4", "claude-3"])
monkeypatch.setattr(
"app.gateway.routers.models.get_optional_user_from_request",
AsyncMock(return_value=_user()),
)
with TestClient(_make_models_app(app_config)) as client:
response = client.get("/api/models")
assert response.status_code == 200
names = [m["name"] for m in response.json()["models"]]
assert names == ["gpt-4", "claude-3"]
@pytest.mark.parametrize(
("fail_closed", "expected_count"),
[(True, 0), (False, 3)],
)
def test_list_models_provider_error_fail_closed_vs_open(monkeypatch, fail_closed, expected_count):
"""Provider error → empty (fail-closed) or all (fail-open)."""
provider = _RecordingProvider(errors={"model"})
_enable_authorization(monkeypatch, provider, fail_closed=fail_closed)
app_config = _make_app_config(["gpt-4", "claude-3", "llama-3"])
app_config.authorization.fail_closed = fail_closed
monkeypatch.setattr(
"app.gateway.routers.models.get_optional_user_from_request",
AsyncMock(return_value=_user()),
)
with TestClient(_make_models_app(app_config)) as client:
response = client.get("/api/models")
assert response.status_code == 200
assert len(response.json()["models"]) == expected_count
# ── get_model tests ────────────────────────────────────────────────────
def test_get_model_disabled_returns_model(monkeypatch):
"""When authorization is disabled, get_model works as before."""
config = AuthorizationConfig(enabled=False)
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: config)
app_config = _make_app_config(["gpt-4"])
monkeypatch.setattr(
"app.gateway.routers.models.get_optional_user_from_request",
AsyncMock(return_value=_user()),
)
with TestClient(_make_models_app(app_config)) as client:
response = client.get("/api/models/gpt-4")
assert response.status_code == 200
assert response.json()["name"] == "gpt-4"
def test_get_model_404_when_not_found(monkeypatch):
"""Non-existent model returns 404 regardless of authorization."""
provider = RbacAuthorizationProvider(roles={"user": {"models": {"allow": "*"}}})
_enable_authorization(monkeypatch, provider)
app_config = _make_app_config(["gpt-4"])
monkeypatch.setattr(
"app.gateway.routers.models.get_optional_user_from_request",
AsyncMock(return_value=_user()),
)
with TestClient(_make_models_app(app_config)) as client:
response = client.get("/api/models/nonexistent")
assert response.status_code == 404
def test_get_model_denied_returns_403(monkeypatch):
"""Role denied model:use → 403 (not 404)."""
provider = RbacAuthorizationProvider(
roles={"user": {"models": {"allow": ["claude-3"]}}},
)
_enable_authorization(monkeypatch, provider)
app_config = _make_app_config(["gpt-4", "claude-3"])
monkeypatch.setattr(
"app.gateway.routers.models.get_optional_user_from_request",
AsyncMock(return_value=_user()),
)
with TestClient(_make_models_app(app_config)) as client:
response = client.get("/api/models/gpt-4")
assert response.status_code == 403
def test_get_model_allowed_returns_200(monkeypatch):
"""Role allowed model:use → 200."""
provider = RbacAuthorizationProvider(
roles={"user": {"models": {"allow": ["gpt-4", "claude-3"]}}},
)
_enable_authorization(monkeypatch, provider)
app_config = _make_app_config(["gpt-4", "claude-3"])
monkeypatch.setattr(
"app.gateway.routers.models.get_optional_user_from_request",
AsyncMock(return_value=_user()),
)
with TestClient(_make_models_app(app_config)) as client:
response = client.get("/api/models/gpt-4")
assert response.status_code == 200
assert response.json()["name"] == "gpt-4"
@pytest.mark.parametrize(
("fail_closed", "expected_status"),
[(True, 403), (False, 200)],
)
def test_get_model_provider_error_fail_closed_vs_open(monkeypatch, fail_closed, expected_status):
"""Provider error on model:use → 403 (fail-closed) or 200 (fail-open)."""
provider = _RecordingProvider(errors={"gpt-4"})
_enable_authorization(monkeypatch, provider, fail_closed=fail_closed)
app_config = _make_app_config(["gpt-4"])
app_config.authorization.fail_closed = fail_closed
monkeypatch.setattr(
"app.gateway.routers.models.get_optional_user_from_request",
AsyncMock(return_value=_user()),
)
with TestClient(_make_models_app(app_config)) as client:
response = client.get("/api/models/gpt-4")
assert response.status_code == expected_status
@pytest.mark.parametrize(
("fail_closed", "expected_status"),
[(True, 403), (False, 200)],
)
def test_get_model_provider_unavailable_fail_closed_vs_open(monkeypatch, fail_closed, expected_status):
"""Provider *resolution* failure → 403 (fail-closed) or 200 (fail-open).
Distinct from ``test_get_model_provider_error_fail_closed_vs_open``: that
test exercises a provider that resolves but errors inside ``authorize``.
This one exercises ``_AuthorizationUnavailable`` (the provider cannot be
resolved at all, e.g. misconfigured class path) and pins the fail-open
path so the docstring's "provider resolution error yields 403 (fail-closed)
or allows the request (fail-open)" claim is backed by a test.
"""
config = AuthorizationConfig(
enabled=True,
fail_closed=fail_closed,
default_role="user",
)
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: config)
# Force provider resolution to raise → _AuthorizationUnavailable.
def _boom(_config):
raise RuntimeError("provider class path invalid")
monkeypatch.setattr("app.gateway.authz._get_cached_route_provider", _boom)
app_config = _make_app_config(["gpt-4"])
app_config.authorization.fail_closed = fail_closed
monkeypatch.setattr(
"app.gateway.routers.models.get_optional_user_from_request",
AsyncMock(return_value=_user()),
)
with TestClient(_make_models_app(app_config)) as client:
response = client.get("/api/models/gpt-4")
assert response.status_code == expected_status
# ── Runtime model resolution tests (_authorize_model_name) ─────────────
def _rbac_context(**overrides):
"""Build a minimal run context dict for build_principal_from_context."""
values = {
"user_id": "user-123",
"user_role": "user",
"oauth_provider": "github",
"oauth_id": "oauth-456",
"is_internal": False,
}
values.update(overrides)
return values
def _enable_runtime_authorization(monkeypatch, provider) -> AuthorizationConfig:
"""Patch resolve_authorization_provider in agent.py to return *provider*.
Returns an enabled AuthorizationConfig the caller assigns to app_config.
"""
monkeypatch.setattr(
"deerflow.agents.lead_agent.agent.resolve_authorization_provider",
lambda config: provider,
)
return AuthorizationConfig(enabled=True, fail_closed=True, default_role="user")
def test_authorize_model_name_disabled_is_noop():
"""When authorization is disabled, model name is returned unchanged."""
from deerflow.agents.lead_agent.agent import _authorize_model_name
app_config = _make_app_config(["gpt-4", "claude-3"])
# AuthorizationConfig() defaults to enabled=False.
result = _authorize_model_name("gpt-4", context=_rbac_context(), app_config=app_config)
assert result == "gpt-4"
def test_authorize_model_name_allowed_returns_same(monkeypatch):
"""Allowed model → returned unchanged."""
from deerflow.agents.lead_agent.agent import _authorize_model_name
provider = RbacAuthorizationProvider(
roles={"user": {"models": {"allow": ["gpt-4", "claude-3"]}}},
)
app_config = _make_app_config(["gpt-4", "claude-3"])
app_config.authorization = _enable_runtime_authorization(monkeypatch, provider)
result = _authorize_model_name("gpt-4", context=_rbac_context(), app_config=app_config)
assert result == "gpt-4"
def test_authorize_model_name_denied_falls_back_gracefully(monkeypatch):
"""Denied model → falls back to first allowed model (RFC §9)."""
from deerflow.agents.lead_agent.agent import _authorize_model_name
provider = RbacAuthorizationProvider(
roles={"user": {"models": {"allow": ["claude-3"]}}},
)
app_config = _make_app_config(["gpt-4", "claude-3"])
app_config.authorization = _enable_runtime_authorization(monkeypatch, provider)
result = _authorize_model_name("gpt-4", context=_rbac_context(), app_config=app_config)
assert result == "claude-3"
def test_authorize_model_name_all_denied_fail_closed_raises(monkeypatch):
"""All models denied + fail_closed → ValueError."""
from deerflow.agents.lead_agent.agent import _authorize_model_name
provider = RbacAuthorizationProvider(
roles={"user": {"models": {"allow": []}}},
)
app_config = _make_app_config(["gpt-4", "claude-3"])
app_config.authorization = _enable_runtime_authorization(monkeypatch, provider)
with pytest.raises(ValueError, match="No models are authorized"):
_authorize_model_name("gpt-4", context=_rbac_context(), app_config=app_config)
def test_authorize_model_name_all_denied_fail_open_returns_original(monkeypatch):
"""All models denied + fail_open → returns original model name."""
from deerflow.agents.lead_agent.agent import _authorize_model_name
provider = RbacAuthorizationProvider(
roles={"user": {"models": {"allow": []}}},
)
app_config = _make_app_config(["gpt-4", "claude-3"])
app_config.authorization = AuthorizationConfig(
enabled=True,
fail_closed=False,
default_role="user",
)
monkeypatch.setattr(
"deerflow.agents.lead_agent.agent.resolve_authorization_provider",
lambda config: provider,
)
result = _authorize_model_name("gpt-4", context=_rbac_context(), app_config=app_config)
assert result == "gpt-4"
def test_authorize_model_name_custom_provider_list_vs_use_divergence(monkeypatch):
"""Custom provider that allows 'list' but denies 'use' → model is denied.
Regression for willem-bd's forward-looking note: a custom provider that
distinguishes ``list`` from ``use`` must not let a model through the
runtime path just because ``filter_resources`` includes it. The runtime
path checks ``authorize("model", "use")`` first; only on deny does it
fall back to ``filter_resources`` to pick a replacement.
"""
class _ListButNotUseProvider:
"""Allows listing gpt-4 but denies using it."""
name = "list-not-use"
def authorize(self, request):
if request.resource == "model" and request.action == "use" and request.target == "gpt-4":
return AuthzDecision(allow=False, reasons=[AuthzReason(code="authz.denied")])
return AuthzDecision(allow=True, reasons=[AuthzReason(code="authz.allowed")])
async def aauthorize(self, request):
return self.authorize(request)
def filter_resources(self, principal, resource_type, candidates):
# gpt-4 is "visible" (listable) but not "usable"
return list(candidates)
app_config = _make_app_config(["gpt-4", "claude-3"])
app_config.authorization = AuthorizationConfig(enabled=True, fail_closed=True, default_role="user")
monkeypatch.setattr(
"deerflow.agents.lead_agent.agent.resolve_authorization_provider",
lambda config: _ListButNotUseProvider(),
)
# gpt-4 is listable but denied for use → falls back to claude-3
from deerflow.agents.lead_agent.agent import _authorize_model_name
result = _authorize_model_name("gpt-4", context=_rbac_context(), app_config=app_config)
assert result != "gpt-4"
assert result == "claude-3"
def test_authorize_model_name_custom_provider_no_usable_fallback_fail_closed(monkeypatch):
"""All visible models denied for use + fail_closed → ValueError.
Regression for willem-bd's edge-case note: when ``filter_resources``
returns only models that are themselves denied for ``use``, the fallback
must NOT silently reselect a denied model. With ``fail_closed=True`` it
must raise; with ``fail_closed=False`` it returns the original name.
"""
class _AllListNoneUseProvider:
"""Lists all models but denies use for every one of them."""
name = "all-list-none-use"
def authorize(self, request):
if request.resource == "model" and request.action == "use":
return AuthzDecision(allow=False, reasons=[AuthzReason(code="authz.denied")])
return AuthzDecision(allow=True, reasons=[AuthzReason(code="authz.allowed")])
async def aauthorize(self, request):
return self.authorize(request)
def filter_resources(self, principal, resource_type, candidates):
return list(candidates) # all visible
app_config = _make_app_config(["gpt-4", "claude-3"])
app_config.authorization = AuthorizationConfig(enabled=True, fail_closed=True, default_role="user")
monkeypatch.setattr(
"deerflow.agents.lead_agent.agent.resolve_authorization_provider",
lambda config: _AllListNoneUseProvider(),
)
from deerflow.agents.lead_agent.agent import _authorize_model_name
# gpt-4 denied for use; fallback candidates also denied → ValueError
with pytest.raises(ValueError, match="No models are authorized"):
_authorize_model_name("gpt-4", context=_rbac_context(), app_config=app_config)
def test_authorize_model_name_custom_provider_no_usable_fallback_fail_open(monkeypatch):
"""All visible models denied for use + fail_open → returns original name."""
class _AllListNoneUseProvider:
name = "all-list-none-use"
def authorize(self, request):
if request.resource == "model" and request.action == "use":
return AuthzDecision(allow=False, reasons=[AuthzReason(code="authz.denied")])
return AuthzDecision(allow=True, reasons=[AuthzReason(code="authz.allowed")])
async def aauthorize(self, request):
return self.authorize(request)
def filter_resources(self, principal, resource_type, candidates):
return list(candidates)
app_config = _make_app_config(["gpt-4", "claude-3"])
app_config.authorization = AuthorizationConfig(enabled=True, fail_closed=False, default_role="user")
monkeypatch.setattr(
"deerflow.agents.lead_agent.agent.resolve_authorization_provider",
lambda config: _AllListNoneUseProvider(),
)
from deerflow.agents.lead_agent.agent import _authorize_model_name
result = _authorize_model_name("gpt-4", context=_rbac_context(), app_config=app_config)
assert result == "gpt-4"
# ── DeerFlowClient._ensure_agent path ─────────────────────────────────
# Regression for willem-bd's Round 4 coverage observation: the embedded/library
# lead-agent construction path (``DeerFlowClient._ensure_agent``) must enforce
# ``model:use`` too, not just the Gateway runtime path (``_make_lead_agent``).
# Otherwise a consumer that enables ``authorization`` with role-scoped model
# policies gets tools filtered yet can still run a model the role is denied
# ``use`` for, diverging from the contract this PR establishes.
def test_client_ensure_agent_enforces_model_use_when_authorized(monkeypatch):
"""``_ensure_agent`` routes the resolved model through ``_authorize_model_name``.
Real-path test: we let the genuine ``_authorize_model_name`` run against a
real RBAC provider (only ``resolve_authorization_provider`` is patched, as
in the runtime tests above) so the full ``client → authz gate → RBAC →
fallback → create_chat_model`` chain is exercised — not just "the gate was
called". The provider allows only ``claude-3`` for ``use``, so the denied
``gpt-4`` must be swapped for ``claude-3`` before reaching the model factory.
"""
from langchain_core.runnables import RunnableConfig
app_config = _make_app_config(["gpt-4", "claude-3"])
app_config.authorization = AuthorizationConfig(enabled=True, fail_closed=True, default_role="user")
provider = RbacAuthorizationProvider(roles={"user": {"models": {"allow": ["claude-3"]}}})
monkeypatch.setattr(
"deerflow.agents.lead_agent.agent.resolve_authorization_provider",
lambda config: provider,
)
captured_name = _stub_client_assembly(monkeypatch)
client = _bare_client(app_config)
config: RunnableConfig = {"configurable": {"model_name": "gpt-4", "user_id": "user-123", "user_role": "user"}}
client._ensure_agent(config)
# Denied ``gpt-4`` was swapped for the authorized fallback ``claude-3``.
assert captured_name["name"] == "claude-3"
def test_client_ensure_agent_resolves_none_default_before_authorization(monkeypatch):
"""A ``None`` model name is resolved to the default before the authz gate.
Guards the ``create_chat_model(name=None)`` semantic: when the caller omits
``model_name`` the implicit default (first configured model) must still pass
``model:use`` — otherwise the embedded path could run an unauthorized default.
"""
from langchain_core.runnables import RunnableConfig
app_config = _make_app_config(["gpt-4", "claude-3"])
app_config.authorization = AuthorizationConfig(enabled=True, fail_closed=True, default_role="user")
# Deny the default ``gpt-4``; the gate must fallback to ``claude-3``.
provider = RbacAuthorizationProvider(roles={"user": {"models": {"allow": ["claude-3"]}}})
monkeypatch.setattr(
"deerflow.agents.lead_agent.agent.resolve_authorization_provider",
lambda config: provider,
)
captured_name = _stub_client_assembly(monkeypatch)
client = _bare_client(app_config)
# No model_name supplied → defaults to ``gpt-4`` (first configured) → denied → fallback.
config: RunnableConfig = {"configurable": {"user_id": "user-123", "user_role": "user"}}
client._ensure_agent(config)
assert captured_name["name"] == "claude-3"
def test_client_ensure_agent_noop_when_authorization_disabled(monkeypatch):
"""When ``authorization.enabled`` is false, ``_ensure_agent`` leaves the model unchanged."""
from langchain_core.runnables import RunnableConfig
app_config = _make_app_config(["gpt-4"])
# AuthorizationConfig() defaults to enabled=False.
captured_name = _stub_client_assembly(monkeypatch)
client = _bare_client(app_config)
config: RunnableConfig = {"configurable": {"model_name": "gpt-4"}}
client._ensure_agent(config)
# Disabled → gate is a no-op: original name passed straight through.
assert captured_name["name"] == "gpt-4"
def _stub_client_assembly(monkeypatch) -> dict[str, str]:
"""Stub the heavy dependencies ``_ensure_agent`` pulls in after the authz gate.
Returns a dict the caller can inspect to see what ``create_chat_model`` got.
Everything here is downstream of the contract under test, so we replace it
with no-ops to keep the test focused on the ``_authorize_model_name`` call.
"""
captured: dict[str, str] = {}
monkeypatch.setattr(
"deerflow.client.create_chat_model",
lambda **kwargs: captured.__setitem__("name", kwargs.get("name")) or object(),
)
monkeypatch.setattr("deerflow.client.create_agent", lambda **kwargs: object())
monkeypatch.setattr("deerflow.client.build_middlewares", lambda *args, **kwargs: [])
monkeypatch.setattr("deerflow.client.DeerFlowClient._get_tools", staticmethod(lambda *, model_name, subagent_enabled: [])) # noqa: ARG005
monkeypatch.setattr("deerflow.client.get_enabled_skills_for_config", lambda app_config: []) # noqa: ARG005
monkeypatch.setattr(
"deerflow.client.build_skill_search_setup",
lambda skills, *, enabled, container_base_path: SimpleNamespace(describe_skill_tool=None, skill_names=frozenset()), # noqa: ARG005
)
monkeypatch.setattr(
"deerflow.client.assemble_deferred_tools",
lambda tools, *, enabled: ([], SimpleNamespace(deferred_names=frozenset())), # noqa: ARG005
)
monkeypatch.setattr("deerflow.client.build_mcp_routing_middleware", lambda *args, **kwargs: None) # noqa: ARG005
monkeypatch.setattr("deerflow.client.get_mcp_routing_hints_prompt_section", lambda *args, **kwargs: "") # noqa: ARG005
monkeypatch.setattr("deerflow.client.apply_prompt_template", lambda **kwargs: "") # noqa: ARG005
monkeypatch.setattr("deerflow.client.get_thread_state_schema", lambda *args, **kwargs: object()) # noqa: ARG005
monkeypatch.setattr("deerflow.client.normalize_middleware_state_schemas", lambda schemas, mode, freq: []) # noqa: ARG005
monkeypatch.setattr("deerflow.client.get_effective_user_id", lambda: "user-123")
# ``apply_tool_authorization`` (called with the empty tool list above) still
# resolves a provider via ``tool_filter.resolve_authorization_provider``; route
# it at an allow-all RBAC provider so the empty list stays empty.
monkeypatch.setattr(
"deerflow.authz.tool_filter.resolve_authorization_provider",
lambda config: RbacAuthorizationProvider(roles={"user": {"tools": {"allow": "*"}}}),
)
return captured
def _bare_client(app_config):
"""Construct a ``DeerFlowClient`` without running ``__init__``."""
from deerflow.client import DeerFlowClient
client = DeerFlowClient.__new__(DeerFlowClient)
client._app_config = app_config
client._agent_name = "default"
client._available_skills = None
client._checkpoint_channel_mode = "full"
client._checkpoint_snapshot_frequency = None
client._middlewares = []
client._agent = None
client._agent_config_key = None
# Non-None so ``_ensure_agent`` skips the real (postgres/sqlite) checkpointer
# resolution — the value is never used because ``create_agent`` is stubbed.
client._checkpointer = object()
return client