deer-flow/backend/tests/test_authorization_enforcement.py
hataa 7857fa0cce
feat(authz): enforce tool authorization at assembly and runtime (#4370)
* feat(authz): enforce tool authorization at assembly and runtime

* fix(middleware): guard deferred tool setup lookup (#4370)

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-23 22:51:35 +08:00

391 lines
16 KiB
Python

"""Tests for Phase 1B tool authorization enforcement."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from langchain_core.tools import StructuredTool
from deerflow.agents.lead_agent import agent as lead_agent_module
from deerflow.agents.middlewares.tool_error_handling_middleware import (
build_lead_runtime_middlewares,
build_subagent_runtime_middlewares,
)
from deerflow.authz.adapter import GuardrailAuthorizationAdapter
from deerflow.authz.enforcement import filter_tools_by_authorization
from deerflow.authz.provider import AuthzDecision, AuthzReason, Principal
from deerflow.authz.rbac import RbacAuthorizationProvider
from deerflow.config.app_config import AppConfig
from deerflow.config.authorization_config import AuthorizationConfig, AuthorizationProviderConfig
from deerflow.config.guardrails_config import GuardrailProviderConfig, GuardrailsConfig
from deerflow.config.model_config import ModelConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.guardrails.middleware import GuardrailMiddleware
from deerflow.tools.builtins.tool_search import assemble_deferred_tools
from deerflow.tools.mcp_metadata import tag_mcp_tool
def _tool(name: str) -> StructuredTool:
return StructuredTool.from_function(lambda: name, name=name, description=name)
class _FilterProvider:
name = "filter"
def __init__(self, allowed: list[str]) -> None:
self.allowed = allowed
self.calls: list[tuple[Principal, str, list[str]]] = []
def authorize(self, request):
return AuthzDecision(allow=True)
async def aauthorize(self, request):
return self.authorize(request)
def filter_resources(self, principal: Principal, resource_type: str, candidates: list[str]) -> list[str]:
self.calls.append((principal, resource_type, candidates))
return [candidate for candidate in candidates if candidate in self.allowed]
class _ExplodingFilterProvider(_FilterProvider):
def __init__(self) -> None:
super().__init__([])
def filter_resources(self, principal: Principal, resource_type: str, candidates: list[str]) -> list[str]:
raise RuntimeError("provider failed")
def _app_config(
*,
authorization: AuthorizationConfig,
guardrails: GuardrailsConfig | None = None,
models: list[ModelConfig] | None = None,
) -> AppConfig:
return AppConfig(
models=models or [],
sandbox=SandboxConfig(use="test"),
authorization=authorization,
guardrails=guardrails or GuardrailsConfig(),
)
class TestAuthorizationToolFilter:
def test_keeps_only_provider_allowed_tools_and_preserves_input_order(self):
provider = _FilterProvider(["web_search", "read_file"])
tools = [_tool("bash"), _tool("web_search"), _tool("read_file")]
filtered = filter_tools_by_authorization(
tools,
provider=provider,
principal=Principal(role="user"),
fail_closed=True,
)
assert [tool.name for tool in filtered] == ["web_search", "read_file"]
assert provider.calls == [(Principal(role="user"), "tool", ["bash", "web_search", "read_file"])]
def test_provider_error_fails_closed_to_an_empty_tool_set(self):
tools = [_tool("bash"), _tool("web_search")]
filtered = filter_tools_by_authorization(
tools,
provider=_ExplodingFilterProvider(),
principal=Principal(role="user"),
fail_closed=True,
)
assert filtered == []
def test_provider_error_fails_open_only_when_configured(self):
tools = [_tool("bash"), _tool("web_search")]
filtered = filter_tools_by_authorization(
tools,
provider=_ExplodingFilterProvider(),
principal=Principal(role="user"),
fail_closed=False,
)
assert filtered == tools
@pytest.mark.parametrize("invalid_result", ["bash", ("bash",), ["bash", 1]])
def test_invalid_provider_result_fails_closed(self, invalid_result):
class _InvalidResultProvider(_FilterProvider):
def filter_resources(self, principal, resource_type, candidates):
return invalid_result
filtered = filter_tools_by_authorization(
[_tool("bash")],
provider=_InvalidResultProvider([]),
principal=Principal(role="user"),
fail_closed=True,
)
assert filtered == []
def test_provider_cannot_add_tools_outside_the_candidate_set(self):
class _InjectingProvider(_FilterProvider):
def filter_resources(self, principal, resource_type, candidates):
return [*candidates, "injected_tool"]
filtered = filter_tools_by_authorization(
[_tool("bash")],
provider=_InjectingProvider([]),
principal=Principal(role="user"),
fail_closed=True,
)
assert [tool.name for tool in filtered] == ["bash"]
class TestAuthorizationGuardrailWiring:
def test_deferred_tool_search_bypasses_layer_two_for_filtered_catalog(self):
provider = RbacAuthorizationProvider(roles={"guest": {"tools": {"allow": ["mcp_allowed"]}}})
filtered_tools = filter_tools_by_authorization(
[tag_mcp_tool(_tool("mcp_allowed"))],
provider=provider,
principal=Principal(role="guest"),
fail_closed=True,
)
_final_tools, deferred_setup = assemble_deferred_tools(filtered_tools, enabled=True)
config = _app_config(
authorization=AuthorizationConfig(
enabled=True,
default_role="guest",
provider=AuthorizationProviderConfig(use="unused:Provider"),
)
)
middlewares = build_lead_runtime_middlewares(
app_config=config,
authorization_provider=provider,
deferred_setup=deferred_setup,
)
authorization_middleware = next(middleware for middleware in middlewares if isinstance(middleware, GuardrailMiddleware) and isinstance(middleware.provider, GuardrailAuthorizationAdapter))
request = MagicMock()
request.tool_call = {
"name": "tool_search",
"args": {"query": "mcp_allowed"},
"id": "call-search",
}
request.runtime = SimpleNamespace(context={"user_role": "guest"})
expected = MagicMock()
handler = MagicMock(return_value=expected)
result = authorization_middleware.wrap_tool_call(request, handler)
assert result is expected
handler.assert_called_once_with(request)
def test_tool_search_without_deferred_catalog_is_not_exempt(self):
provider = RbacAuthorizationProvider(roles={"guest": {"tools": {"allow": ["web_search"]}}})
config = _app_config(
authorization=AuthorizationConfig(
enabled=True,
default_role="guest",
provider=AuthorizationProviderConfig(use="unused:Provider"),
)
)
middlewares = build_lead_runtime_middlewares(
app_config=config,
authorization_provider=provider,
)
authorization_middleware = next(middleware for middleware in middlewares if isinstance(middleware, GuardrailMiddleware) and isinstance(middleware.provider, GuardrailAuthorizationAdapter))
request = MagicMock()
request.tool_call = {"name": "tool_search", "args": {}, "id": "call-search"}
request.runtime = SimpleNamespace(context={"user_role": "guest"})
handler = MagicMock()
result = authorization_middleware.wrap_tool_call(request, handler)
assert result.status == "error"
handler.assert_not_called()
def test_subagent_deferred_tool_search_bypasses_layer_two_async(self):
provider = RbacAuthorizationProvider(roles={"guest": {"tools": {"allow": ["mcp_allowed"]}}})
filtered_tools = filter_tools_by_authorization(
[tag_mcp_tool(_tool("mcp_allowed"))],
provider=provider,
principal=Principal(role="guest"),
fail_closed=True,
)
_final_tools, deferred_setup = assemble_deferred_tools(filtered_tools, enabled=True)
config = _app_config(
authorization=AuthorizationConfig(
enabled=True,
default_role="guest",
provider=AuthorizationProviderConfig(use="unused:Provider"),
)
)
middlewares = build_subagent_runtime_middlewares(
app_config=config,
authorization_provider=provider,
deferred_setup=deferred_setup,
)
authorization_middleware = next(middleware for middleware in middlewares if isinstance(middleware, GuardrailMiddleware) and isinstance(middleware.provider, GuardrailAuthorizationAdapter))
request = MagicMock()
request.tool_call = {
"name": "tool_search",
"args": {"query": "mcp_allowed"},
"id": "call-search",
}
request.runtime = SimpleNamespace(context={"user_role": "guest"})
expected = MagicMock()
handler = AsyncMock(return_value=expected)
result = asyncio.run(authorization_middleware.awrap_tool_call(request, handler))
assert result is expected
handler.assert_awaited_once_with(request)
def test_authorization_wires_adapter_with_the_build_provider_instance(self):
provider = _FilterProvider(["bash"])
config = _app_config(
authorization=AuthorizationConfig(
enabled=True,
provider=AuthorizationProviderConfig(use="deerflow.authz.rbac:RbacAuthorizationProvider", config={"roles": {"user": {}}}),
)
)
middlewares = build_lead_runtime_middlewares(app_config=config, authorization_provider=provider)
authorization_middleware = next(middleware for middleware in middlewares if isinstance(middleware, GuardrailMiddleware) and isinstance(middleware.provider, GuardrailAuthorizationAdapter))
assert authorization_middleware.provider._provider is provider
assert authorization_middleware.fail_closed is True
def test_authorization_and_explicit_guardrail_both_run(self):
provider = _FilterProvider(["bash"])
config = _app_config(
authorization=AuthorizationConfig(
enabled=True,
provider=AuthorizationProviderConfig(use="deerflow.authz.rbac:RbacAuthorizationProvider", config={"roles": {"user": {}}}),
),
guardrails=GuardrailsConfig(
enabled=True,
provider=GuardrailProviderConfig(
use="deerflow.guardrails.builtin:AllowlistProvider",
config={"allowed_tools": ["bash"]},
),
),
)
middlewares = build_lead_runtime_middlewares(app_config=config, authorization_provider=provider)
guardrails = [middleware for middleware in middlewares if isinstance(middleware, GuardrailMiddleware)]
assert len(guardrails) == 2
assert isinstance(guardrails[0].provider, GuardrailAuthorizationAdapter)
assert guardrails[0].provider._provider is provider
assert type(guardrails[1].provider).__name__ == "AllowlistProvider"
def test_wired_authorization_middleware_denies_execution_with_runtime_principal(self):
class _DenyingProvider(_FilterProvider):
def __init__(self):
super().__init__(["bash"])
self.requests = []
def authorize(self, request):
self.requests.append(request)
return AuthzDecision(
allow=False,
reasons=[AuthzReason(code="authz.denied", message="blocked")],
)
provider = _DenyingProvider()
config = _app_config(
authorization=AuthorizationConfig(
enabled=True,
provider=AuthorizationProviderConfig(use="unused:Provider"),
)
)
middlewares = build_lead_runtime_middlewares(
app_config=config,
authorization_provider=provider,
)
authorization_middleware = next(middleware for middleware in middlewares if isinstance(middleware, GuardrailMiddleware) and isinstance(middleware.provider, GuardrailAuthorizationAdapter))
request = MagicMock()
request.tool_call = {"name": "bash", "args": {"command": "whoami"}, "id": "call-1"}
request.runtime = SimpleNamespace(
context={
"user_id": "u1",
"user_role": "guest",
"thread_id": "t1",
"run_id": "r1",
}
)
handler = MagicMock()
result = authorization_middleware.wrap_tool_call(request, handler)
handler.assert_not_called()
assert result.status == "error"
assert "authz.denied" in result.content
assert provider.requests[0].principal == Principal(user_id="u1", role="guest")
assert provider.requests[0].target == "bash"
assert provider.requests[0].context["run_id"] == "r1"
@pytest.mark.parametrize("is_bootstrap", [False, True])
def test_lead_agent_filters_all_model_visible_tools_and_reuses_provider(monkeypatch, is_bootstrap):
"""Layer 1 covers late framework tools and Layer 2 receives its provider."""
config = _app_config(
authorization=AuthorizationConfig(
enabled=True,
provider=AuthorizationProviderConfig(
use="deerflow.authz.rbac:RbacAuthorizationProvider",
config={"roles": {"user": {"tools": {"allow": ["safe_tool"]}}}},
),
),
models=[
ModelConfig(
name="test-model",
display_name="Test model",
use="langchain_openai:ChatOpenAI",
model="test-model",
)
],
)
config.skills.deferred_discovery = True
monkeypatch.setattr(lead_agent_module, "_resolve_model_name", lambda *args, **kwargs: "test-model")
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: object())
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
monkeypatch.setattr(lead_agent_module, "apply_prompt_template", lambda **kwargs: "prompt")
monkeypatch.setattr(lead_agent_module, "build_tracing_callbacks", lambda: [])
monkeypatch.setattr(lead_agent_module, "_load_enabled_available_skills", lambda *args, **kwargs: [])
monkeypatch.setattr(
lead_agent_module,
"build_skill_search_setup",
lambda *args, **kwargs: SimpleNamespace(
describe_skill_tool=_tool("describe_skill"),
skill_names=frozenset({"example"}),
),
raising=False,
)
monkeypatch.setattr("deerflow.skills.describe.build_skill_search_setup", lead_agent_module.build_skill_search_setup)
monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: [_tool("safe_tool"), _tool("denied_tool")])
monkeypatch.setattr(lead_agent_module, "should_use_memory_tools", lambda memory_config: True)
monkeypatch.setattr(
lead_agent_module,
"_append_memory_tools_without_name_conflicts",
lambda tools: tools.append(_tool("memory_search")),
)
captured: dict[str, object] = {}
def _capture_middlewares(*args, **kwargs):
captured.update(kwargs)
return []
monkeypatch.setattr(lead_agent_module, "build_middlewares", _capture_middlewares)
runtime_context = {"user_role": "user"}
if is_bootstrap:
runtime_context["is_bootstrap"] = True
result = lead_agent_module._make_lead_agent({"context": runtime_context}, app_config=config)
assert [tool.name for tool in result["tools"]] == ["safe_tool"]
assert captured["authorization_provider"] is not None