mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-25 15:37:53 +00:00
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>
This commit is contained in:
parent
6aad680596
commit
7857fa0cce
@ -672,6 +672,8 @@ uv run python -m deerflow.skills.review.cli ../skills/public/data-analysis --for
|
||||
|
||||
Tools follow the same philosophy. DeerFlow comes with a core toolset — web search, web fetch, rendered web capture, file operations, bash execution — and supports custom tools via MCP servers and Python functions. Swap anything. Add anything.
|
||||
|
||||
Advanced deployments can enable pluggable tool authorization with `authorization.enabled` in `config.yaml`. A configured `AuthorizationProvider` filters denied tools before they reach the model or deferred-tool catalog, then the same provider is checked again before every business-tool execution through the existing guardrail middleware. A generated `tool_search` may bypass that second check only when it fronts the current build's already-filtered deferred catalog. The built-in RBAC provider supports per-role tool allow/deny policies and validates that `default_role` names a configured role; authorization is disabled by default. See `config.example.yaml` and the [authorization RFC](docs/plans/2026-07-10-pluggable-authorization-rfc.md).
|
||||
|
||||
Advanced deployments can also extend the agent runtime itself by declaring zero-argument `AgentMiddleware` classes under `extensions.middlewares` in `config.yaml` or `extensions_config.json`. DeerFlow loads the same configured class list into the lead-agent and subagent pipelines after their built-in runtime middlewares and loop/token guards, but before the terminal-response/safety/clarification tail, so enterprise forks can add domain guardrails, tool-call governance, or observability hooks without patching the built-in middleware builders. Missing packages, invalid classes, and broken modules fail loudly at agent creation. Treat `config.yaml` and `extensions_config.json` as trusted operator-controlled files: middleware paths are code execution, just like custom tool, model, sandbox, guardrail, MCP server, and MCP interceptor declarations. Gateway skill/MCP toggle endpoints preserve this field but do not expose an API write path for `extensions.middlewares`. Per-context parameterization and separate lead-only/subagent-only middleware lists are not supported yet.
|
||||
|
||||
Gateway-generated follow-up suggestions now normalize both plain-string model output and block/list-style rich content before parsing the JSON array response, so provider-specific content wrappers do not silently drop suggestions.
|
||||
|
||||
@ -315,13 +315,13 @@ Lead-agent middlewares are assembled in strict order across three functions: the
|
||||
6. **SandboxMiddleware** - Acquires sandbox, stores `sandbox_id` in state
|
||||
7. **DanglingToolCallMiddleware** - Injects placeholder ToolMessages for AIMessage tool_calls that lack responses (e.g., user interruption), preserving raw provider tool-call payloads in `additional_kwargs["tool_calls"]`; malformed tool-call names and arguments are sanitized in the model-bound request so strict OpenAI-compatible providers do not reject the next request
|
||||
8. **LLMErrorHandlingMiddleware** - Normalizes provider/model invocation failures into recoverable assistant-facing errors before later stages run
|
||||
9. **GuardrailMiddleware** - *(optional, if `guardrails.enabled`)* Pre-tool-call authorization via pluggable `GuardrailProvider`; returns an error ToolMessage on deny. Providers: built-in `AllowlistProvider` (zero deps), OAP policy providers (e.g. `aport-agent-guardrails`), or custom. See [docs/GUARDRAILS.md](docs/GUARDRAILS.md)
|
||||
9. **Authorization / GuardrailMiddleware** - Up to two independent pre-tool-call gates run here. When `authorization.enabled`, the `AuthorizationProvider` instance already used for Layer 1 capability filtering is wrapped by `GuardrailAuthorizationAdapter` and reused for Layer 2 execution checks. A generated `tool_search` bypasses the adapter's second provider call only when the current build has a concrete deferred setup; its catalog was already filtered by Layer 1, and an ordinary same-named tool without that deferred setup receives no exemption. When `guardrails.enabled`, the explicitly configured `GuardrailProvider` is appended after authorization and still evaluates every call, including `tool_search`. Authorization therefore runs outermost and can deny before an external guardrail call; both use the existing middleware's fail-closed, audit, sync/async, and error-`ToolMessage` behavior. See the authorization RFC and [docs/GUARDRAILS.md](docs/GUARDRAILS.md).
|
||||
10. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution
|
||||
11. **ReadBeforeWriteMiddleware** - *(optional, if `read_before_write.enabled`, default on)* Outermost write gate (issue #3857): `read_file` stamps a content hash onto its ToolMessage; `write_file` (append/overwrite-existing) and `str_replace` are blocked unless the newest mark for that path matches the file's current hash. Sits outside ToolProgressMiddleware and ToolErrorHandlingMiddleware so a blocked write returns immediately without consuming a ToolProgress slot. Blocked results call `normalize_tool_result` directly to stamp `deerflow_tool_meta` (`recoverable_by_model=True`) before returning, keeping the result well-formed for any outer consumer. Marks live on messages, so summarization dropping the read result invalidates the gate automatically; writes never refresh marks, forcing a re-read between consecutive edits. Gate check + tool execution are serialized per (thread, path) so same-turn parallel writes cannot reuse one stale mark; on sandboxes whose `read_file` reports failures as `"Error: ..."` strings instead of raising (AIO/E2B), uninspectable targets fail open (creation proceeds, no mark stamped)
|
||||
12. **ToolProgressMiddleware** - *(optional, if `tool_progress.enabled`)* State-machine-based stagnation guard (RFC #3177). Outer wrapper around ToolErrorHandlingMiddleware so its `wrap_tool_call` receives results already stamped with `deerflow_tool_meta`. Tracks per-(thread, tool) consecutive "no-new-info" calls across three error categories: (a) `recoverable_by_model=True` (no_results, not_found, permission, Jaccard-duplicate success): ACTIVE → WARNED (terminal — hint re-injected on each subsequent problem); (b) `recoverable_by_model=False, action≠stop` (rate_limited, transient): ACTIVE → WARNED → BLOCKED after `warn_escalation_count` more problems; (c) `recoverable_by_model=False, action=stop` (auth, config, internal): immediately BLOCKED on first occurrence. **Division of labor with LoopDetectionMiddleware:** ToolProgressMiddleware is a result-quality guard — fires after tool execution and blocks specific tools that stop producing new information; LoopDetectionMiddleware is a call-pattern guard — fires after the model responds and hard-stops the whole turn when the model repeatedly issues identical tool_calls. Both can inject HumanMessage hints in the same model call without conflict; neither reads the other's internal state.
|
||||
13. **ToolErrorHandlingMiddleware** - Receives `AppConfig`, converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting, stamps every result with `deerflow_tool_meta` (status / error_type / recoverable_by_model / recommended_next_action / source) via `tool_result_meta.normalize_tool_result`, stamps structured metadata for task exception wrappers, and stamps skill-read metadata for downstream durable-context capture. Task tool result text is generated from the same status/result/error inputs as the structured metadata so callers do not hand-write a second protocol string.
|
||||
|
||||
Authorization identity plumbing is independent of whether authorization enforcement is enabled. Gateway removes client-supplied `is_internal` / `authz_attributes` / `channel_user_id`, derives `is_internal` only from the server-owned `request.state.auth_source`, and accepts `channel_user_id` only from an internally authenticated IM caller's top-level `body.context`; free-form `body.config` can never supply it. `build_principal_from_context` is the shared Principal builder for assembly-time authorization and `GuardrailAuthorizationAdapter`; it applies `default_role`, strict-boolean internal provenance, and copy-on-read `authz_attributes`. Task delegation carries `is_internal` plus copied attributes through `SubagentExecutor`, while `GuardrailMiddleware` maps the same runtime fields into `GuardrailRequest`. Phase 1A provides this trusted identity chain only; automatic Layer 1 filtering and Layer 2 authorization middleware wiring remain separate enforcement work.
|
||||
Authorization identity plumbing is independent of whether authorization enforcement is enabled. Gateway removes client-supplied `is_internal` / `authz_attributes` / `channel_user_id`, derives `is_internal` only from the server-owned `request.state.auth_source`, and accepts `channel_user_id` only from an internally authenticated IM caller's top-level `body.context`; free-form `body.config` can never supply it. `build_principal_from_context` is the shared Principal builder for assembly-time authorization and `GuardrailAuthorizationAdapter`; it applies `default_role`, strict-boolean internal provenance, and copy-on-read `authz_attributes`. The built-in RBAC provider validates `authorization.default_role` during provider resolution so an unknown fallback role fails agent construction instead of degrading into an empty tool set. Task delegation carries `is_internal` plus copied attributes through `SubagentExecutor`, while `GuardrailMiddleware` maps the same runtime fields into `GuardrailRequest`. Phase 1B applies Layer 1 before deferred-tool assembly on the lead, native-subagent, and embedded-client paths, then passes the same provider instance into Layer 2. Framework-provided `describe_skill` and memory tools are included in Layer 1 but restored to their legacy post-`tool_search` ordering afterward. `DeerFlowClient.stream()` treats its in-process caller as trusted and accepts the same identity fields as keyword overrides; it includes the complete Principal in its agent cache key and deep-copies nested attributes so caller mutation cannot make a stale tool set look current.
|
||||
|
||||
Before changing a later authorization phase, read the [authorization RFC](../docs/plans/2026-07-10-pluggable-authorization-rfc.md) and its [implementation notes](../docs/plans/2026-07-10-pluggable-authorization-implementation-notes.md). The notes are the cumulative handoff record for merged PR behavior, reviewer feedback, trust-boundary decisions, deferred scope, and required regression coverage.
|
||||
|
||||
|
||||
@ -46,6 +46,7 @@ from deerflow.agents.middlewares.token_usage_middleware import TokenUsageMiddlew
|
||||
from deerflow.agents.middlewares.tool_error_handling_middleware import build_lead_runtime_middlewares
|
||||
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
|
||||
from deerflow.agents.thread_state import get_thread_state_schema, normalize_middleware_state_schemas
|
||||
from deerflow.authz.tool_filter import apply_tool_authorization
|
||||
from deerflow.config.agents_config import load_agent_config, validate_agent_name
|
||||
from deerflow.config.app_config import AppConfig, get_app_config
|
||||
from deerflow.config.memory_config import should_use_memory_tools
|
||||
@ -273,6 +274,7 @@ def build_middlewares(
|
||||
deferred_setup=None,
|
||||
mcp_routing_middleware: AgentMiddleware | None = None,
|
||||
user_id: str | None = None,
|
||||
authorization_provider=None,
|
||||
):
|
||||
"""Build the lead-agent middleware chain based on runtime configuration.
|
||||
|
||||
@ -293,12 +295,22 @@ def build_middlewares(
|
||||
deferred MCP schemas before the deferred filter runs.
|
||||
user_id: Effective user ID for user-scoped skill loading. Passed through
|
||||
to ``SkillActivationMiddleware`` so it can resolve per-user custom skills.
|
||||
authorization_provider: Provider already resolved for assembly-time
|
||||
filtering. Reused by the execution-time authorization middleware.
|
||||
|
||||
Returns:
|
||||
List of middleware instances.
|
||||
"""
|
||||
resolved_app_config = app_config or get_app_config()
|
||||
middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True)
|
||||
runtime_middleware_kwargs = {
|
||||
"app_config": resolved_app_config,
|
||||
"lazy_init": True,
|
||||
}
|
||||
if authorization_provider is not None:
|
||||
runtime_middleware_kwargs["authorization_provider"] = authorization_provider
|
||||
if authorization_provider is not None and deferred_setup is not None:
|
||||
runtime_middleware_kwargs["deferred_setup"] = deferred_setup
|
||||
middlewares = build_lead_runtime_middlewares(**runtime_middleware_kwargs)
|
||||
|
||||
# Always inject current date (and optionally memory) as <system-reminder> into the
|
||||
# first HumanMessage to keep the system prompt fully static for prefix-cache reuse.
|
||||
@ -622,16 +634,26 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
configured_tools = raw_tools
|
||||
if non_interactive:
|
||||
configured_tools = [tool for tool in configured_tools if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES]
|
||||
authorization_candidates = [*configured_tools]
|
||||
if skill_setup.describe_skill_tool:
|
||||
authorization_candidates.append(skill_setup.describe_skill_tool)
|
||||
if should_use_memory_tools(resolved_app_config.memory):
|
||||
_append_memory_tools_without_name_conflicts(authorization_candidates)
|
||||
configured_tool_ids = {id(tool) for tool in configured_tools}
|
||||
authorized_tools, _authz_provider = apply_tool_authorization(
|
||||
authorization_candidates,
|
||||
context=cfg,
|
||||
app_config=resolved_app_config,
|
||||
)
|
||||
configured_tools = [tool for tool in authorized_tools if id(tool) in configured_tool_ids]
|
||||
late_tools = [tool for tool in authorized_tools if id(tool) not in configured_tool_ids]
|
||||
final_tools, setup = assemble_deferred_tools(configured_tools, enabled=resolved_app_config.tool_search.enabled)
|
||||
final_tools.extend(late_tools)
|
||||
mcp_routing_middleware = build_mcp_routing_middleware(
|
||||
final_tools,
|
||||
setup,
|
||||
top_k=resolved_app_config.tool_search.auto_promote_top_k,
|
||||
)
|
||||
if skill_setup.describe_skill_tool:
|
||||
final_tools.append(skill_setup.describe_skill_tool)
|
||||
if should_use_memory_tools(resolved_app_config.memory):
|
||||
_append_memory_tools_without_name_conflicts(final_tools)
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False),
|
||||
tools=final_tools,
|
||||
@ -644,6 +666,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
deferred_setup=setup,
|
||||
mcp_routing_middleware=mcp_routing_middleware,
|
||||
user_id=resolved_user_id,
|
||||
authorization_provider=_authz_provider,
|
||||
),
|
||||
mode,
|
||||
),
|
||||
@ -691,17 +714,27 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
configured_tools = raw_tools + extra_tools
|
||||
if non_interactive:
|
||||
configured_tools = [tool for tool in configured_tools if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES]
|
||||
authorization_candidates = [*configured_tools]
|
||||
if skill_setup.describe_skill_tool:
|
||||
authorization_candidates.append(skill_setup.describe_skill_tool)
|
||||
if should_use_memory_tools(resolved_app_config.memory):
|
||||
_append_memory_tools_without_name_conflicts(authorization_candidates)
|
||||
configured_tool_ids = {id(tool) for tool in configured_tools}
|
||||
authorized_tools, _authz_provider = apply_tool_authorization(
|
||||
authorization_candidates,
|
||||
context=cfg,
|
||||
app_config=resolved_app_config,
|
||||
)
|
||||
configured_tools = [tool for tool in authorized_tools if id(tool) in configured_tool_ids]
|
||||
late_tools = [tool for tool in authorized_tools if id(tool) not in configured_tool_ids]
|
||||
final_tools, setup = assemble_deferred_tools(configured_tools, enabled=resolved_app_config.tool_search.enabled)
|
||||
final_tools.extend(late_tools)
|
||||
mcp_routing_middleware = build_mcp_routing_middleware(
|
||||
final_tools,
|
||||
setup,
|
||||
top_k=resolved_app_config.tool_search.auto_promote_top_k,
|
||||
)
|
||||
mcp_routing_hints_section = get_mcp_routing_hints_prompt_section(configured_tools, deferred_names=setup.deferred_names)
|
||||
if skill_setup.describe_skill_tool:
|
||||
final_tools.append(skill_setup.describe_skill_tool)
|
||||
if should_use_memory_tools(resolved_app_config.memory):
|
||||
_append_memory_tools_without_name_conflicts(final_tools)
|
||||
mcp_routing_hints_section = get_mcp_routing_hints_prompt_section(authorized_tools, deferred_names=setup.deferred_names)
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config, attach_tracing=False, model_overrides=agent_model_overrides),
|
||||
tools=final_tools,
|
||||
@ -715,6 +748,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
deferred_setup=setup,
|
||||
mcp_routing_middleware=mcp_routing_middleware,
|
||||
user_id=resolved_user_id,
|
||||
authorization_provider=_authz_provider,
|
||||
),
|
||||
mode,
|
||||
),
|
||||
|
||||
@ -157,6 +157,8 @@ def _build_runtime_middlewares(
|
||||
include_uploads: bool,
|
||||
include_dangling_tool_call_patch: bool,
|
||||
lazy_init: bool = True,
|
||||
authorization_provider=None,
|
||||
authorization_infrastructure_tool_names: frozenset[str] = frozenset(),
|
||||
) -> list[AgentMiddleware]:
|
||||
"""Build shared base middlewares for agent execution."""
|
||||
from deerflow.agents.middlewares.input_sanitization_middleware import InputSanitizationMiddleware
|
||||
@ -199,7 +201,33 @@ def _build_runtime_middlewares(
|
||||
tail.append(DanglingToolCallMiddleware())
|
||||
tail.append(LLMErrorHandlingMiddleware(app_config=app_config))
|
||||
|
||||
# Guardrail middleware (if configured)
|
||||
# Authorization uses the existing GuardrailMiddleware so execution-time
|
||||
# deny, audit, and fail-closed handling stay in one proven implementation.
|
||||
# It is appended before an explicit guardrail provider, making authorization
|
||||
# the outer guard and avoiding an unnecessary external policy call for an
|
||||
# already-denied tool.
|
||||
authorization_config = app_config.authorization
|
||||
if authorization_config.enabled is True:
|
||||
if authorization_provider is None:
|
||||
from deerflow.authz.runtime import resolve_authorization_provider
|
||||
|
||||
authorization_provider = resolve_authorization_provider(authorization_config)
|
||||
if authorization_provider is not None:
|
||||
from deerflow.authz.adapter import GuardrailAuthorizationAdapter
|
||||
from deerflow.guardrails.middleware import GuardrailMiddleware
|
||||
|
||||
tail.append(
|
||||
GuardrailMiddleware(
|
||||
GuardrailAuthorizationAdapter(
|
||||
authorization_provider,
|
||||
default_role=authorization_config.default_role,
|
||||
infrastructure_tool_names=authorization_infrastructure_tool_names,
|
||||
),
|
||||
fail_closed=authorization_config.fail_closed,
|
||||
)
|
||||
)
|
||||
|
||||
# Explicit guardrail middleware remains independently active when configured.
|
||||
guardrails_config = app_config.guardrails
|
||||
if guardrails_config.enabled and guardrails_config.provider:
|
||||
import inspect
|
||||
@ -264,13 +292,21 @@ def _build_runtime_middlewares(
|
||||
return middlewares
|
||||
|
||||
|
||||
def build_lead_runtime_middlewares(*, app_config: AppConfig, lazy_init: bool = True) -> list[AgentMiddleware]:
|
||||
def build_lead_runtime_middlewares(
|
||||
*,
|
||||
app_config: AppConfig,
|
||||
lazy_init: bool = True,
|
||||
authorization_provider=None,
|
||||
deferred_setup: "DeferredToolSetup | None" = None,
|
||||
) -> list[AgentMiddleware]:
|
||||
"""Middlewares shared by lead agent runtime before lead-only middlewares."""
|
||||
return _build_runtime_middlewares(
|
||||
app_config=app_config,
|
||||
include_uploads=True,
|
||||
include_dangling_tool_call_patch=True,
|
||||
lazy_init=lazy_init,
|
||||
authorization_provider=authorization_provider,
|
||||
authorization_infrastructure_tool_names=(frozenset({deferred_setup.tool_search_tool.name}) if authorization_provider is not None and deferred_setup is not None and deferred_setup.tool_search_tool is not None else frozenset()),
|
||||
)
|
||||
|
||||
|
||||
@ -282,6 +318,7 @@ def build_subagent_runtime_middlewares(
|
||||
deferred_setup: "DeferredToolSetup | None" = None,
|
||||
mcp_routing_middleware: AgentMiddleware | None = None,
|
||||
agent_name: str | None = None,
|
||||
authorization_provider=None,
|
||||
) -> list[AgentMiddleware]:
|
||||
"""Middlewares shared by subagent runtime before subagent-only middlewares."""
|
||||
if app_config is None:
|
||||
@ -294,6 +331,8 @@ def build_subagent_runtime_middlewares(
|
||||
include_uploads=False,
|
||||
include_dangling_tool_call_patch=True,
|
||||
lazy_init=lazy_init,
|
||||
authorization_provider=authorization_provider,
|
||||
authorization_infrastructure_tool_names=(frozenset({deferred_setup.tool_search_tool.name}) if authorization_provider is not None and deferred_setup is not None and deferred_setup.tool_search_tool is not None else frozenset()),
|
||||
)
|
||||
|
||||
if model_name is None and app_config.models:
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
"""Pluggable fine-grained authorization (resource-level RBAC and beyond)."""
|
||||
|
||||
from deerflow.authz.adapter import GuardrailAuthorizationAdapter
|
||||
from deerflow.authz.enforcement import filter_tools_by_authorization
|
||||
from deerflow.authz.principal import build_principal_from_context, normalize_authz_attributes
|
||||
from deerflow.authz.provider import AuthorizationProvider, AuthzDecision, AuthzReason, AuthzRequest, Principal
|
||||
from deerflow.authz.rbac import RbacAuthorizationProvider
|
||||
from deerflow.authz.runtime import resolve_authorization_provider
|
||||
from deerflow.authz.tool_filter import apply_tool_authorization
|
||||
|
||||
__all__ = [
|
||||
"AuthzDecision",
|
||||
@ -14,7 +16,9 @@ __all__ = [
|
||||
"GuardrailAuthorizationAdapter",
|
||||
"Principal",
|
||||
"RbacAuthorizationProvider",
|
||||
"apply_tool_authorization",
|
||||
"build_principal_from_context",
|
||||
"filter_tools_by_authorization",
|
||||
"normalize_authz_attributes",
|
||||
"resolve_authorization_provider",
|
||||
]
|
||||
|
||||
@ -17,6 +17,8 @@ with consistent ``default_role`` and ``attributes`` semantics.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from deerflow.authz.principal import build_principal_from_context
|
||||
from deerflow.authz.provider import AuthorizationProvider, AuthzDecision, AuthzRequest
|
||||
from deerflow.guardrails.provider import GuardrailDecision, GuardrailReason, GuardrailRequest
|
||||
@ -36,6 +38,10 @@ class GuardrailAuthorizationAdapter:
|
||||
``AuthorizationConfig.default_role``.
|
||||
resource_type: Resource type for all ``AuthzRequest`` instances.
|
||||
action: Action for all ``AuthzRequest`` instances.
|
||||
infrastructure_tool_names: Framework tools created from an already
|
||||
authorized capability set. These may execute without a second
|
||||
provider decision; callers must derive the names from the current
|
||||
build's concrete deferred setup rather than from static config.
|
||||
"""
|
||||
|
||||
name = "authorization"
|
||||
@ -47,11 +53,23 @@ class GuardrailAuthorizationAdapter:
|
||||
default_role: str = "user",
|
||||
resource_type: str = "tool",
|
||||
action: str = "call",
|
||||
infrastructure_tool_names: Iterable[str] = (),
|
||||
) -> None:
|
||||
self._provider = provider
|
||||
self._default_role = default_role
|
||||
self._resource_type = resource_type
|
||||
self._action = action
|
||||
self._infrastructure_tool_names = frozenset(infrastructure_tool_names)
|
||||
|
||||
def _infrastructure_decision(self, request: GuardrailRequest) -> GuardrailDecision | None:
|
||||
"""Allow framework tools created from an already-filtered capability set."""
|
||||
if request.tool_name not in self._infrastructure_tool_names:
|
||||
return None
|
||||
return GuardrailDecision(
|
||||
allow=True,
|
||||
reasons=[GuardrailReason(code="authz.infrastructure_tool")],
|
||||
policy_id="authz:infrastructure",
|
||||
)
|
||||
|
||||
def _to_authz(self, gr: GuardrailRequest) -> AuthzRequest:
|
||||
"""Map a guardrail request to an authorization request."""
|
||||
@ -104,6 +122,8 @@ class GuardrailAuthorizationAdapter:
|
||||
here would duplicate that logic and risk divergent behavior between
|
||||
the two layers.
|
||||
"""
|
||||
if infrastructure_decision := self._infrastructure_decision(request):
|
||||
return infrastructure_decision
|
||||
decision = self._provider.authorize(self._to_authz(request))
|
||||
return self._to_guardrail(decision)
|
||||
|
||||
@ -112,5 +132,7 @@ class GuardrailAuthorizationAdapter:
|
||||
|
||||
See :meth:`evaluate` for exception-propagation rationale.
|
||||
"""
|
||||
if infrastructure_decision := self._infrastructure_decision(request):
|
||||
return infrastructure_decision
|
||||
decision = await self._provider.aauthorize(self._to_authz(request))
|
||||
return self._to_guardrail(decision)
|
||||
|
||||
42
backend/packages/harness/deerflow/authz/enforcement.py
Normal file
42
backend/packages/harness/deerflow/authz/enforcement.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Shared Phase 1B authorization enforcement helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
from deerflow.authz.provider import AuthorizationProvider, Principal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def filter_tools_by_authorization(
|
||||
tools: Sequence[BaseTool],
|
||||
*,
|
||||
provider: AuthorizationProvider | None,
|
||||
principal: Principal,
|
||||
fail_closed: bool,
|
||||
) -> list[BaseTool]:
|
||||
"""Return the policy-visible subset of *tools* without changing its order.
|
||||
|
||||
The caller must invoke this before deferred-tool assembly. Provider errors
|
||||
and malformed filter results deny every tool when ``fail_closed`` is true;
|
||||
an explicitly configured fail-open policy preserves the original set.
|
||||
"""
|
||||
original_tools = list(tools)
|
||||
if provider is None:
|
||||
return original_tools
|
||||
|
||||
candidates = [tool.name for tool in original_tools]
|
||||
try:
|
||||
allowed = provider.filter_resources(principal, "tool", candidates)
|
||||
if not isinstance(allowed, list) or any(not isinstance(name, str) for name in allowed):
|
||||
raise TypeError("AuthorizationProvider.filter_resources must return list[str]")
|
||||
except Exception:
|
||||
logger.exception("Authorization provider failed while filtering tools")
|
||||
return [] if fail_closed else original_tools
|
||||
|
||||
allowed_names = set(allowed)
|
||||
return [tool for tool in original_tools if tool.name in allowed_names]
|
||||
@ -121,6 +121,14 @@ class RbacAuthorizationProvider:
|
||||
compiled = self._compile_resource_policy(role_name, resource_key, resource_policy)
|
||||
self._policies[(role_name, resource_key)] = compiled
|
||||
|
||||
def validate_role(self, role: str, *, field: str = "role") -> None:
|
||||
"""Fail fast when an operator-configured role is not defined."""
|
||||
role = _require_non_empty_string(role, field=field)
|
||||
if role not in self._known_roles:
|
||||
if field == "role":
|
||||
raise ValueError(f"Unknown role '{role}'; known roles: {sorted(self._known_roles)}")
|
||||
raise ValueError(f"{field} '{role}' is not defined; known roles: {sorted(self._known_roles)}")
|
||||
|
||||
@staticmethod
|
||||
def _compile_resource_policy(
|
||||
role_name: str,
|
||||
@ -194,8 +202,7 @@ class RbacAuthorizationProvider:
|
||||
if role is None or role == "":
|
||||
raise ValueError("Principal has no role; cannot evaluate RBAC policy")
|
||||
|
||||
if role not in self._known_roles:
|
||||
raise ValueError(f"Unknown role '{role}'; known roles: {sorted(self._known_roles)}")
|
||||
self.validate_role(role)
|
||||
|
||||
resource = _require_non_empty_string(resource, field=resource_field)
|
||||
resource_key = _RESOURCE_POLICY_KEYS.get(resource, resource)
|
||||
|
||||
@ -47,4 +47,12 @@ def resolve_authorization_provider(
|
||||
if not isinstance(instance, AuthorizationProvider):
|
||||
raise ValueError(f"Authorization provider '{class_path}' does not satisfy the AuthorizationProvider Protocol")
|
||||
|
||||
from deerflow.authz.rbac import RbacAuthorizationProvider
|
||||
|
||||
if isinstance(instance, RbacAuthorizationProvider):
|
||||
try:
|
||||
instance.validate_role(config.default_role, field="authorization.default_role")
|
||||
except ValueError as err:
|
||||
raise ValueError(f"Invalid authorization default_role for provider '{class_path}': {err}") from err
|
||||
|
||||
return instance
|
||||
|
||||
72
backend/packages/harness/deerflow/authz/tool_filter.py
Normal file
72
backend/packages/harness/deerflow/authz/tool_filter.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""Convenience wrapper for Layer 1 tool authorization filtering.
|
||||
|
||||
Combines provider resolution, Principal construction, and tool filtering into
|
||||
a single call so the three assembly paths (lead agent, subagent, embedded
|
||||
client) stay one-liners.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
from deerflow.authz.enforcement import filter_tools_by_authorization
|
||||
from deerflow.authz.principal import build_principal_from_context
|
||||
from deerflow.authz.provider import AuthorizationProvider
|
||||
from deerflow.authz.runtime import resolve_authorization_provider
|
||||
from deerflow.config.app_config import AppConfig
|
||||
|
||||
|
||||
def apply_tool_authorization(
|
||||
tools: list[BaseTool],
|
||||
*,
|
||||
context: Mapping[str, Any],
|
||||
app_config: AppConfig,
|
||||
authorization_provider: AuthorizationProvider | None = None,
|
||||
) -> tuple[list[BaseTool], AuthorizationProvider | None]:
|
||||
"""Apply Layer 1 tool authorization filtering.
|
||||
|
||||
Resolves the provider (or reuses a caller-provided one so Layer 1 and
|
||||
Layer 2 share a single instance), builds a Principal from *context*, and
|
||||
filters *tools* in place by the provider's policy.
|
||||
|
||||
When ``authorization.enabled`` is false, this is a no-op: returns the
|
||||
original tools and ``None``.
|
||||
|
||||
Args:
|
||||
tools: Candidate tools (already skill-filtered etc.).
|
||||
context: Runtime context mapping (the merged ``cfg`` dict or an
|
||||
equivalent dict assembled from ``self.*`` fields).
|
||||
app_config: The resolved AppConfig (used for authorization settings).
|
||||
authorization_provider: An already-resolved provider, or ``None`` to
|
||||
resolve from ``app_config.authorization`` here.
|
||||
|
||||
Returns:
|
||||
``(filtered_tools, provider)`` — the filtered tool list and the
|
||||
provider instance (for passing to Layer 2 middleware wiring, or
|
||||
``None`` when authorization is disabled).
|
||||
"""
|
||||
authz_config = app_config.authorization
|
||||
# Guard against Mock objects in tests: MagicMock attribute access returns
|
||||
# a truthy child mock for ``enabled``, which would trigger provider
|
||||
# resolution on a non-string ``provider.use``. Real AuthorizationConfig
|
||||
# has ``enabled: bool``; if it's not actually ``True``, skip.
|
||||
if authz_config.enabled is not True:
|
||||
return tools, None
|
||||
|
||||
if authorization_provider is None:
|
||||
authorization_provider = resolve_authorization_provider(authz_config)
|
||||
|
||||
if authorization_provider is None:
|
||||
return tools, None
|
||||
|
||||
principal = build_principal_from_context(context, default_role=authz_config.default_role)
|
||||
filtered = filter_tools_by_authorization(
|
||||
tools,
|
||||
provider=authorization_provider,
|
||||
principal=principal,
|
||||
fail_closed=authz_config.fail_closed,
|
||||
)
|
||||
return filtered, authorization_provider
|
||||
@ -17,6 +17,7 @@ Usage:
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
@ -24,7 +25,7 @@ import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
from collections.abc import Generator, Sequence
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
@ -37,6 +38,7 @@ from langchain_core.runnables import RunnableConfig
|
||||
from deerflow.agents.lead_agent.agent import build_middlewares
|
||||
from deerflow.agents.lead_agent.prompt import apply_prompt_template, get_enabled_skills_for_config
|
||||
from deerflow.agents.thread_state import get_thread_state_schema, normalize_middleware_state_schemas
|
||||
from deerflow.authz.principal import build_principal_from_context
|
||||
from deerflow.config.agents_config import AGENT_NAME_PATTERN
|
||||
from deerflow.config.app_config import get_app_config, is_trace_correlation_enabled, reload_app_config
|
||||
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
||||
@ -68,6 +70,18 @@ from deerflow.uploads.manager import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EMBEDDED_AUTHORIZATION_CONTEXT_KEYS = frozenset(
|
||||
{
|
||||
"user_id",
|
||||
"user_role",
|
||||
"oauth_provider",
|
||||
"oauth_id",
|
||||
"channel_user_id",
|
||||
"is_internal",
|
||||
"authz_attributes",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _run_async_from_sync(coro):
|
||||
"""Run an async helper from this synchronous client API."""
|
||||
@ -242,9 +256,27 @@ class DeerFlowClient:
|
||||
recursion_limit=overrides.get("recursion_limit", 100),
|
||||
)
|
||||
|
||||
def _ensure_agent(self, config: RunnableConfig):
|
||||
def _ensure_agent(self, config: RunnableConfig, *, context: Mapping[str, Any] | None = None):
|
||||
"""Create (or recreate) the agent when config-dependent params change."""
|
||||
cfg = config.get("configurable", {})
|
||||
cfg = dict(config.get("configurable", {}) or {})
|
||||
if context is not None:
|
||||
cfg.update(context)
|
||||
|
||||
authorization_identity = None
|
||||
if self._app_config.authorization.enabled:
|
||||
principal = build_principal_from_context(
|
||||
cfg,
|
||||
default_role=self._app_config.authorization.default_role,
|
||||
)
|
||||
authorization_identity = (
|
||||
principal.user_id,
|
||||
principal.role,
|
||||
principal.oauth_provider,
|
||||
principal.oauth_id,
|
||||
principal.channel_user_id,
|
||||
principal.is_internal,
|
||||
copy.deepcopy(principal.attributes),
|
||||
)
|
||||
key = (
|
||||
cfg.get("model_name"),
|
||||
cfg.get("thinking_enabled"),
|
||||
@ -255,6 +287,7 @@ class DeerFlowClient:
|
||||
self._agent_name,
|
||||
frozenset(self._available_skills) if self._available_skills is not None else None,
|
||||
self._checkpoint_channel_mode,
|
||||
authorization_identity,
|
||||
)
|
||||
|
||||
if self._agent is not None and self._agent_config_key == key:
|
||||
@ -267,15 +300,9 @@ class DeerFlowClient:
|
||||
max_total_subagents = cfg.get("max_total_subagents", self._app_config.subagents.max_total_per_run)
|
||||
|
||||
tools = self._get_tools(model_name=model_name, subagent_enabled=subagent_enabled)
|
||||
final_tools, deferred_setup = assemble_deferred_tools(tools, enabled=self._app_config.tool_search.enabled)
|
||||
mcp_routing_middleware = build_mcp_routing_middleware(
|
||||
final_tools,
|
||||
deferred_setup,
|
||||
top_k=self._app_config.tool_search.auto_promote_top_k,
|
||||
)
|
||||
mcp_routing_hints_section = get_mcp_routing_hints_prompt_section(tools, deferred_names=deferred_setup.deferred_names)
|
||||
|
||||
# Wire deferred skill discovery — mirrors agent.py so config flag works on both paths.
|
||||
# Add framework-provided tools before authorization so Layer 1 sees
|
||||
# every capability that can become model-visible.
|
||||
skills_list = get_enabled_skills_for_config(self._app_config)
|
||||
if self._available_skills is not None:
|
||||
skills_list = [s for s in skills_list if s.name in self._available_skills]
|
||||
@ -284,8 +311,31 @@ class DeerFlowClient:
|
||||
enabled=self._app_config.skills.deferred_discovery,
|
||||
container_base_path=self._app_config.skills.container_path,
|
||||
)
|
||||
late_tools = []
|
||||
if skill_setup.describe_skill_tool:
|
||||
final_tools.append(skill_setup.describe_skill_tool)
|
||||
late_tools.append(skill_setup.describe_skill_tool)
|
||||
|
||||
# Apply authorization Layer 1 before deferred assembly.
|
||||
from deerflow.authz.tool_filter import apply_tool_authorization
|
||||
|
||||
configured_tool_ids = {id(tool) for tool in tools}
|
||||
authorized_tools, _authz_provider = apply_tool_authorization(
|
||||
[*tools, *late_tools],
|
||||
context=cfg,
|
||||
app_config=self._app_config,
|
||||
)
|
||||
tools = [tool for tool in authorized_tools if id(tool) in configured_tool_ids]
|
||||
late_tools = [tool for tool in authorized_tools if id(tool) not in configured_tool_ids]
|
||||
final_tools, deferred_setup = assemble_deferred_tools(tools, enabled=self._app_config.tool_search.enabled)
|
||||
final_tools.extend(late_tools)
|
||||
mcp_routing_middleware = build_mcp_routing_middleware(
|
||||
final_tools,
|
||||
deferred_setup,
|
||||
top_k=self._app_config.tool_search.auto_promote_top_k,
|
||||
)
|
||||
mcp_routing_hints_section = get_mcp_routing_hints_prompt_section(authorized_tools, deferred_names=deferred_setup.deferred_names)
|
||||
|
||||
effective_user_id = cfg.get("user_id") or get_effective_user_id()
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
# attach_tracing=False because ``stream()`` injects tracing
|
||||
@ -304,7 +354,8 @@ class DeerFlowClient:
|
||||
app_config=self._app_config,
|
||||
deferred_setup=deferred_setup,
|
||||
mcp_routing_middleware=mcp_routing_middleware,
|
||||
user_id=get_effective_user_id(),
|
||||
user_id=effective_user_id,
|
||||
authorization_provider=_authz_provider,
|
||||
),
|
||||
self._checkpoint_channel_mode,
|
||||
),
|
||||
@ -317,7 +368,7 @@ class DeerFlowClient:
|
||||
app_config=self._app_config,
|
||||
deferred_names=deferred_setup.deferred_names,
|
||||
mcp_routing_hints_section=mcp_routing_hints_section,
|
||||
user_id=get_effective_user_id(),
|
||||
user_id=effective_user_id,
|
||||
skill_names=skill_setup.skill_names or None,
|
||||
),
|
||||
"state_schema": get_thread_state_schema(self._checkpoint_channel_mode),
|
||||
@ -739,7 +790,9 @@ class DeerFlowClient:
|
||||
message: User message text.
|
||||
thread_id: Thread ID for conversation context. Auto-generated if None.
|
||||
**kwargs: Override client defaults (model_name, thinking_enabled,
|
||||
plan_mode, subagent_enabled, recursion_limit).
|
||||
plan_mode, subagent_enabled, recursion_limit). Trusted embedded
|
||||
callers may also provide user_id, user_role, oauth_provider,
|
||||
oauth_id, channel_user_id, is_internal, and authz_attributes.
|
||||
|
||||
Yields:
|
||||
StreamEvent with one of:
|
||||
@ -786,23 +839,34 @@ class DeerFlowClient:
|
||||
existing_callbacks = list(config.get("callbacks") or [])
|
||||
config["callbacks"] = [*existing_callbacks, *tracing_callbacks]
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
context: dict[str, Any] = {"thread_id": thread_id, "run_id": run_id}
|
||||
for key in _EMBEDDED_AUTHORIZATION_CONTEXT_KEYS:
|
||||
if key in kwargs:
|
||||
context[key] = kwargs[key]
|
||||
|
||||
configurable = config.get("configurable") or {}
|
||||
deerflow_trace_id = get_current_trace_id()
|
||||
effective_user_id = context.get("user_id") or get_effective_user_id()
|
||||
if self._app_config.authorization.enabled:
|
||||
# Match the existing user-scoped storage/tracing identity when an
|
||||
# embedded caller relies on CurrentUser instead of an explicit
|
||||
# user_id override. Layer 1, Layer 2, and the agent cache must see
|
||||
# the same actor.
|
||||
context["user_id"] = effective_user_id
|
||||
inject_langfuse_metadata(
|
||||
config,
|
||||
thread_id=thread_id,
|
||||
user_id=get_effective_user_id(),
|
||||
user_id=effective_user_id,
|
||||
assistant_id=self._agent_name or "lead-agent",
|
||||
model_name=configurable.get("model_name") or self._model_name,
|
||||
environment=self._environment or os.environ.get("DEER_FLOW_ENV") or os.environ.get("ENVIRONMENT"),
|
||||
deerflow_trace_id=deerflow_trace_id,
|
||||
)
|
||||
|
||||
self._ensure_agent(config)
|
||||
self._ensure_agent(config, context=context)
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
state: dict[str, Any] = {"messages": [HumanMessage(content=message, additional_kwargs={"run_id": run_id})]}
|
||||
context = {"thread_id": thread_id, "run_id": run_id}
|
||||
if deerflow_trace_id:
|
||||
context[DEERFLOW_TRACE_METADATA_KEY] = deerflow_trace_id
|
||||
if self._agent_name:
|
||||
|
||||
@ -520,6 +520,9 @@ class SubagentExecutor:
|
||||
"deferred_setup": deferred_setup,
|
||||
"agent_name": self.config.name,
|
||||
}
|
||||
authz_provider = getattr(self, "_authz_provider", None)
|
||||
if authz_provider is not None:
|
||||
middleware_kwargs["authorization_provider"] = authz_provider
|
||||
if mcp_routing_middleware is not None:
|
||||
middleware_kwargs["mcp_routing_middleware"] = mcp_routing_middleware
|
||||
middlewares = build_subagent_runtime_middlewares(**middleware_kwargs)
|
||||
@ -650,6 +653,27 @@ class SubagentExecutor:
|
||||
# Load skills as conversation items (Codex pattern)
|
||||
skills = await self._load_skills()
|
||||
filtered_tools = self._apply_skill_allowed_tools(skills)
|
||||
|
||||
# Apply authorization Layer 1: filter tools before deferred assembly
|
||||
# so denied tools can never enter the DeferredToolCatalog.
|
||||
from deerflow.authz.tool_filter import apply_tool_authorization
|
||||
|
||||
resolved_app_config = self.app_config or get_app_config()
|
||||
authz_context = {
|
||||
"user_id": self.user_id,
|
||||
"user_role": self.user_role,
|
||||
"oauth_provider": self.oauth_provider,
|
||||
"oauth_id": self.oauth_id,
|
||||
"channel_user_id": self.channel_user_id,
|
||||
"is_internal": self.is_internal,
|
||||
"authz_attributes": self.authz_attributes,
|
||||
}
|
||||
filtered_tools, self._authz_provider = apply_tool_authorization(
|
||||
filtered_tools,
|
||||
context=authz_context,
|
||||
app_config=resolved_app_config,
|
||||
)
|
||||
|
||||
# Assemble deferred tool_search AFTER policy filtering (fail-closed),
|
||||
# mirroring the lead path so subagents stop binding full MCP schemas.
|
||||
# The generated tool_search helper is intentionally not subject to the
|
||||
|
||||
390
backend/tests/test_authorization_enforcement.py
Normal file
390
backend/tests/test_authorization_enforcement.py
Normal file
@ -0,0 +1,390 @@
|
||||
"""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
|
||||
@ -40,6 +40,7 @@ class TestValidProvider:
|
||||
def test_builtin_rbac_resolves(self):
|
||||
config = AuthorizationConfig(
|
||||
enabled=True,
|
||||
default_role="admin",
|
||||
provider=AuthorizationProviderConfig(
|
||||
use="deerflow.authz.rbac:RbacAuthorizationProvider",
|
||||
config={"roles": {"admin": {"tools": {"allow": "*"}}}},
|
||||
@ -160,6 +161,19 @@ class TestRbacErrorPropagation:
|
||||
else:
|
||||
pytest.fail("Expected ValueError for invalid RBAC config")
|
||||
|
||||
def test_unknown_default_role_fails_during_provider_resolution(self):
|
||||
config = AuthorizationConfig(
|
||||
enabled=True,
|
||||
default_role="missing",
|
||||
provider=AuthorizationProviderConfig(
|
||||
use="deerflow.authz.rbac:RbacAuthorizationProvider",
|
||||
config={"roles": {"user": {"tools": {"allow": "*"}}}},
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="default_role.*missing.*known roles"):
|
||||
resolve_authorization_provider(config)
|
||||
|
||||
|
||||
class TestNoFactoryInjection:
|
||||
"""Factory must not inject fail_closed or default_role into provider kwargs."""
|
||||
@ -187,6 +201,7 @@ class TestNoCaching:
|
||||
def test_each_call_returns_new_instance(self):
|
||||
config = AuthorizationConfig(
|
||||
enabled=True,
|
||||
default_role="admin",
|
||||
provider=AuthorizationProviderConfig(
|
||||
use="deerflow.authz.rbac:RbacAuthorizationProvider",
|
||||
config={"roles": {"admin": {"tools": {"allow": "*"}}}},
|
||||
|
||||
245
backend/tests/test_authorization_tool_filter.py
Normal file
245
backend/tests/test_authorization_tool_filter.py
Normal file
@ -0,0 +1,245 @@
|
||||
"""Tests for Phase 1B tool authorization enforcement.
|
||||
|
||||
Covers Layer 1 (tool filtering before deferred assembly) and Layer 2
|
||||
(GuardrailMiddleware via adapter) across the authorization enforcement
|
||||
helpers, plus disabled-parity and fail-closed semantics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langchain_core.tools import BaseTool, StructuredTool
|
||||
|
||||
from deerflow.authz.enforcement import filter_tools_by_authorization
|
||||
from deerflow.authz.provider import Principal
|
||||
from deerflow.authz.rbac import RbacAuthorizationProvider
|
||||
from deerflow.authz.tool_filter import apply_tool_authorization
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.authorization_config import AuthorizationConfig, AuthorizationProviderConfig
|
||||
from deerflow.config.sandbox_config import SandboxConfig
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
|
||||
def _make_tool(name: str) -> BaseTool:
|
||||
"""Create a minimal named tool for testing."""
|
||||
return StructuredTool.from_function(lambda: None, name=name, description="test tool")
|
||||
|
||||
|
||||
def _make_app_config(authz_config: AuthorizationConfig) -> AppConfig:
|
||||
return AppConfig(
|
||||
sandbox=SandboxConfig(use="test"),
|
||||
authorization=authz_config,
|
||||
)
|
||||
|
||||
|
||||
def _rbac_provider() -> RbacAuthorizationProvider:
|
||||
return RbacAuthorizationProvider(
|
||||
roles={
|
||||
"admin": {"tools": {"allow": "*"}},
|
||||
"user": {"tools": {"allow": "*", "deny": ["update_agent", "bash"]}},
|
||||
"guest": {"tools": {"allow": ["web_search", "read_file"]}},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --- filter_tools_by_authorization ---
|
||||
|
||||
|
||||
class TestFilterToolsByAuthorization:
|
||||
def test_provider_none_returns_original(self):
|
||||
"""When authorization is disabled (provider=None), tools pass through unchanged."""
|
||||
tools = [_make_tool("bash"), _make_tool("web_search")]
|
||||
result = filter_tools_by_authorization(
|
||||
tools,
|
||||
provider=None,
|
||||
principal=Principal(role="user"),
|
||||
fail_closed=True,
|
||||
)
|
||||
assert result == tools
|
||||
|
||||
def test_filters_denied_tools(self):
|
||||
"""Denied tools are removed."""
|
||||
provider = _rbac_provider()
|
||||
tools = [_make_tool("bash"), _make_tool("web_search"), _make_tool("read_file")]
|
||||
result = filter_tools_by_authorization(
|
||||
tools,
|
||||
provider=provider,
|
||||
principal=Principal(role="user"),
|
||||
fail_closed=True,
|
||||
)
|
||||
names = [t.name for t in result]
|
||||
assert "bash" not in names
|
||||
assert "web_search" in names
|
||||
assert "read_file" in names
|
||||
|
||||
def test_preserves_order(self):
|
||||
"""Filtering preserves the original tool order."""
|
||||
provider = _rbac_provider()
|
||||
tools = [_make_tool("web_search"), _make_tool("bash"), _make_tool("read_file")]
|
||||
result = filter_tools_by_authorization(
|
||||
tools,
|
||||
provider=provider,
|
||||
principal=Principal(role="user"),
|
||||
fail_closed=True,
|
||||
)
|
||||
assert [t.name for t in result] == ["web_search", "read_file"]
|
||||
|
||||
def test_guest_narrow_allowlist(self):
|
||||
"""Guest role only sees allowed tools."""
|
||||
provider = _rbac_provider()
|
||||
tools = [_make_tool("bash"), _make_tool("web_search"), _make_tool("read_file"), _make_tool("write_file")]
|
||||
result = filter_tools_by_authorization(
|
||||
tools,
|
||||
provider=provider,
|
||||
principal=Principal(role="guest"),
|
||||
fail_closed=True,
|
||||
)
|
||||
assert [t.name for t in result] == ["web_search", "read_file"]
|
||||
|
||||
def test_fail_closed_on_provider_error(self):
|
||||
"""When provider raises and fail_closed=True, deny all tools."""
|
||||
|
||||
class _ExplodingProvider:
|
||||
name = "exploding"
|
||||
|
||||
def authorize(self, request):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
async def aauthorize(self, request):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
def filter_resources(self, principal, resource_type, candidates):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
tools = [_make_tool("bash"), _make_tool("web_search")]
|
||||
result = filter_tools_by_authorization(
|
||||
tools,
|
||||
provider=_ExplodingProvider(),
|
||||
principal=Principal(role="user"),
|
||||
fail_closed=True,
|
||||
)
|
||||
assert result == []
|
||||
|
||||
def test_fail_open_on_provider_error(self):
|
||||
"""When provider raises and fail_closed=False, keep original tools."""
|
||||
|
||||
class _ExplodingProvider:
|
||||
name = "exploding"
|
||||
|
||||
def authorize(self, request):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
async def aauthorize(self, request):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
def filter_resources(self, principal, resource_type, candidates):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
tools = [_make_tool("bash"), _make_tool("web_search")]
|
||||
result = filter_tools_by_authorization(
|
||||
tools,
|
||||
provider=_ExplodingProvider(),
|
||||
principal=Principal(role="user"),
|
||||
fail_closed=False,
|
||||
)
|
||||
assert result == tools
|
||||
|
||||
def test_unknown_role_raises_and_fail_closed_denies_all(self):
|
||||
"""Unknown role causes filter_resources to raise ValueError;
|
||||
fail_closed=True → deny all tools."""
|
||||
provider = _rbac_provider()
|
||||
tools = [_make_tool("bash"), _make_tool("web_search")]
|
||||
result = filter_tools_by_authorization(
|
||||
tools,
|
||||
provider=provider,
|
||||
principal=Principal(role="nonexistent"),
|
||||
fail_closed=True,
|
||||
)
|
||||
assert result == []
|
||||
|
||||
|
||||
# --- apply_tool_authorization (wrapper) ---
|
||||
|
||||
|
||||
class TestApplyToolAuthorization:
|
||||
def test_disabled_returns_original_and_none(self):
|
||||
"""When authorization is disabled, tools unchanged and provider=None."""
|
||||
app_config = _make_app_config(AuthorizationConfig(enabled=False))
|
||||
tools = [_make_tool("bash")]
|
||||
result, provider = apply_tool_authorization(
|
||||
tools,
|
||||
context={},
|
||||
app_config=app_config,
|
||||
)
|
||||
assert result == tools
|
||||
assert provider is None
|
||||
|
||||
def test_enabled_filters_and_returns_provider(self):
|
||||
"""When enabled, tools are filtered and provider is returned for Layer 2 reuse."""
|
||||
app_config = _make_app_config(
|
||||
AuthorizationConfig(
|
||||
enabled=True,
|
||||
provider=AuthorizationProviderConfig(
|
||||
use="deerflow.authz.rbac:RbacAuthorizationProvider",
|
||||
config={"roles": {"user": {"tools": {"allow": "*", "deny": ["bash"]}}}},
|
||||
),
|
||||
)
|
||||
)
|
||||
tools = [_make_tool("bash"), _make_tool("web_search")]
|
||||
result, provider = apply_tool_authorization(
|
||||
tools,
|
||||
context={"user_role": "user"},
|
||||
app_config=app_config,
|
||||
)
|
||||
assert provider is not None
|
||||
assert [t.name for t in result] == ["web_search"]
|
||||
|
||||
def test_disabled_does_not_resolve_provider(self):
|
||||
"""Disabled mode must not attempt to resolve/import the provider."""
|
||||
app_config = _make_app_config(
|
||||
AuthorizationConfig(
|
||||
enabled=False,
|
||||
provider=AuthorizationProviderConfig(use="nonexistent.module:FakeProvider"),
|
||||
)
|
||||
)
|
||||
tools = [_make_tool("bash")]
|
||||
result, provider = apply_tool_authorization(
|
||||
tools,
|
||||
context={},
|
||||
app_config=app_config,
|
||||
)
|
||||
assert result == tools
|
||||
assert provider is None
|
||||
|
||||
def test_reuses_provided_provider(self):
|
||||
"""When caller provides a provider, it's reused (not re-resolved)."""
|
||||
rbac = _rbac_provider()
|
||||
app_config = _make_app_config(AuthorizationConfig(enabled=True))
|
||||
tools = [_make_tool("bash"), _make_tool("web_search")]
|
||||
result, provider = apply_tool_authorization(
|
||||
tools,
|
||||
context={"user_role": "user"},
|
||||
app_config=app_config,
|
||||
authorization_provider=rbac,
|
||||
)
|
||||
assert provider is rbac
|
||||
assert "bash" not in [t.name for t in result]
|
||||
|
||||
|
||||
# --- Disabled parity ---
|
||||
|
||||
|
||||
class TestDisabledParity:
|
||||
"""When authorization.enabled=false, the tool set must be completely unchanged."""
|
||||
|
||||
def test_lead_agent_context_disabled_noop(self):
|
||||
"""Simulate what the lead agent does: apply_tool_authorization with disabled config."""
|
||||
app_config = _make_app_config(AuthorizationConfig(enabled=False))
|
||||
tools = [_make_tool("bash"), _make_tool("web_search"), _make_tool("read_file")]
|
||||
result, provider = apply_tool_authorization(
|
||||
tools,
|
||||
context={"user_role": "admin", "user_id": "u1", "is_internal": True},
|
||||
app_config=app_config,
|
||||
)
|
||||
assert result == tools
|
||||
assert provider is None
|
||||
@ -7,10 +7,12 @@ import tempfile
|
||||
import zipfile
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage, ToolMessage # noqa: F401
|
||||
from langchain_core.tools import StructuredTool
|
||||
|
||||
from app.gateway.routers.mcp import McpConfigResponse
|
||||
from app.gateway.routers.memory import MemoryConfigResponse, MemoryStatusResponse
|
||||
@ -21,9 +23,11 @@ from app.gateway.routers.uploads import UploadResponse
|
||||
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
|
||||
from deerflow.agents.thread_state import DeltaThreadState, ThreadState
|
||||
from deerflow.client import DeerFlowClient
|
||||
from deerflow.config.authorization_config import AuthorizationConfig, AuthorizationProviderConfig
|
||||
from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig
|
||||
from deerflow.config.paths import Paths
|
||||
from deerflow.skills.types import SkillCategory
|
||||
from deerflow.tools.mcp_metadata import tag_mcp_tool
|
||||
from deerflow.uploads.manager import PathTraversalError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -48,6 +52,7 @@ def mock_app_config():
|
||||
config.skills.container_path = "/mnt/skills"
|
||||
config.tool_search.enabled = False
|
||||
config.database.checkpoint_channel_mode = "full"
|
||||
config.authorization = AuthorizationConfig(enabled=False)
|
||||
return config
|
||||
|
||||
|
||||
@ -1023,6 +1028,135 @@ class TestExtractText:
|
||||
|
||||
|
||||
class TestEnsureAgent:
|
||||
def test_authorization_filters_framework_tools_and_reuses_provider(self, client, mock_app_config):
|
||||
class Provider:
|
||||
name = "test"
|
||||
|
||||
def filter_resources(self, principal, resource_type, candidates):
|
||||
return [name for name in candidates if name == "safe_tool"]
|
||||
|
||||
def authorize(self, request):
|
||||
raise AssertionError("not called while assembling")
|
||||
|
||||
async def aauthorize(self, request):
|
||||
raise AssertionError("not called while assembling")
|
||||
|
||||
provider = Provider()
|
||||
mock_app_config.authorization = AuthorizationConfig(
|
||||
enabled=True,
|
||||
provider=AuthorizationProviderConfig(use="unused:Provider"),
|
||||
)
|
||||
mock_app_config.skills.deferred_discovery = True
|
||||
client._app_config = mock_app_config
|
||||
|
||||
safe_tool = StructuredTool.from_function(lambda: "safe", name="safe_tool", description="safe")
|
||||
denied_tool = StructuredTool.from_function(lambda: "denied", name="denied_tool", description="denied")
|
||||
describe_tool = StructuredTool.from_function(lambda: "describe", name="describe_skill", description="describe")
|
||||
|
||||
with (
|
||||
patch("deerflow.client.create_chat_model"),
|
||||
patch("deerflow.client.create_agent", return_value=MagicMock()) as mock_create_agent,
|
||||
patch("deerflow.client.build_middlewares", return_value=[]) as mock_build_middlewares,
|
||||
patch("deerflow.client.apply_prompt_template", return_value="prompt"),
|
||||
patch("deerflow.client.get_enabled_skills_for_config", return_value=[MagicMock()]),
|
||||
patch("deerflow.client.build_skill_search_setup", return_value=SimpleNamespace(describe_skill_tool=describe_tool, skill_names=frozenset({"example"}))),
|
||||
patch.object(client, "_get_tools", return_value=[safe_tool, denied_tool]),
|
||||
patch("deerflow.authz.tool_filter.resolve_authorization_provider", return_value=provider),
|
||||
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None),
|
||||
):
|
||||
client._ensure_agent(client._get_runnable_config("t1"), context={"user_role": "user"})
|
||||
|
||||
assert [tool.name for tool in mock_create_agent.call_args.kwargs["tools"]] == ["safe_tool"]
|
||||
assert mock_build_middlewares.call_args.kwargs["authorization_provider"] is provider
|
||||
|
||||
def test_authorization_cache_key_uses_complete_principal(self, client, mock_app_config):
|
||||
mock_app_config.authorization = AuthorizationConfig(
|
||||
enabled=True,
|
||||
provider=AuthorizationProviderConfig(
|
||||
use="deerflow.authz.rbac:RbacAuthorizationProvider",
|
||||
config={"roles": {"user": {"tools": {"allow": "*"}}}},
|
||||
),
|
||||
)
|
||||
client._app_config = mock_app_config
|
||||
|
||||
with (
|
||||
patch("deerflow.client.create_chat_model"),
|
||||
patch("deerflow.client.create_agent", side_effect=[MagicMock(), MagicMock()]) as mock_create_agent,
|
||||
patch("deerflow.client.build_middlewares", return_value=[]),
|
||||
patch("deerflow.client.apply_prompt_template", return_value="prompt"),
|
||||
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
|
||||
patch.object(client, "_get_tools", return_value=[]),
|
||||
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None),
|
||||
):
|
||||
config = client._get_runnable_config("t1")
|
||||
client._ensure_agent(config, context={"user_id": "u1", "user_role": "user", "authz_attributes": {"department": "eng"}})
|
||||
client._ensure_agent(config, context={"user_id": "u2", "user_role": "user", "authz_attributes": {"department": "eng"}})
|
||||
|
||||
assert mock_create_agent.call_count == 2
|
||||
|
||||
def test_authorization_cache_key_snapshots_nested_attributes(self, client, mock_app_config):
|
||||
mock_app_config.authorization = AuthorizationConfig(
|
||||
enabled=True,
|
||||
provider=AuthorizationProviderConfig(
|
||||
use="deerflow.authz.rbac:RbacAuthorizationProvider",
|
||||
config={"roles": {"user": {"tools": {"allow": "*"}}}},
|
||||
),
|
||||
)
|
||||
client._app_config = mock_app_config
|
||||
attributes = {"groups": ["reader"]}
|
||||
|
||||
with (
|
||||
patch("deerflow.client.create_chat_model"),
|
||||
patch("deerflow.client.create_agent", side_effect=[MagicMock(), MagicMock()]) as mock_create_agent,
|
||||
patch("deerflow.client.build_middlewares", return_value=[]),
|
||||
patch("deerflow.client.apply_prompt_template", return_value="prompt"),
|
||||
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
|
||||
patch.object(client, "_get_tools", return_value=[]),
|
||||
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None),
|
||||
):
|
||||
config = client._get_runnable_config("t1")
|
||||
context = {
|
||||
"user_role": "user",
|
||||
"authz_attributes": attributes,
|
||||
}
|
||||
client._ensure_agent(config, context=context)
|
||||
attributes["groups"].append("admin")
|
||||
client._ensure_agent(config, context=context)
|
||||
|
||||
assert mock_create_agent.call_count == 2
|
||||
|
||||
def test_disabled_authorization_preserves_framework_tool_order(self, client, mock_app_config):
|
||||
mock_app_config.authorization = AuthorizationConfig(enabled=False)
|
||||
mock_app_config.tool_search.enabled = True
|
||||
mock_app_config.skills.deferred_discovery = True
|
||||
client._app_config = mock_app_config
|
||||
mcp_tool = tag_mcp_tool(StructuredTool.from_function(lambda: "mcp", name="mcp_tool", description="mcp"))
|
||||
describe_tool = StructuredTool.from_function(lambda: "describe", name="describe_skill", description="describe")
|
||||
|
||||
with (
|
||||
patch("deerflow.client.create_chat_model"),
|
||||
patch("deerflow.client.create_agent", return_value=MagicMock()) as mock_create_agent,
|
||||
patch("deerflow.client.build_middlewares", return_value=[]),
|
||||
patch("deerflow.client.apply_prompt_template", return_value="prompt"),
|
||||
patch("deerflow.client.get_enabled_skills_for_config", return_value=[MagicMock()]),
|
||||
patch(
|
||||
"deerflow.client.build_skill_search_setup",
|
||||
return_value=SimpleNamespace(
|
||||
describe_skill_tool=describe_tool,
|
||||
skill_names=frozenset({"example"}),
|
||||
),
|
||||
),
|
||||
patch.object(client, "_get_tools", return_value=[mcp_tool]),
|
||||
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None),
|
||||
):
|
||||
client._ensure_agent(client._get_runnable_config("t1"))
|
||||
|
||||
assert [tool.name for tool in mock_create_agent.call_args.kwargs["tools"]] == [
|
||||
"mcp_tool",
|
||||
"tool_search",
|
||||
"describe_skill",
|
||||
]
|
||||
|
||||
def test_creates_agent(self, client):
|
||||
"""_ensure_agent creates an agent on first call."""
|
||||
mock_agent = MagicMock()
|
||||
@ -1141,7 +1275,7 @@ class TestEnsureAgent:
|
||||
"""_ensure_agent does not recreate if config key unchanged."""
|
||||
mock_agent = MagicMock()
|
||||
client._agent = mock_agent
|
||||
client._agent_config_key = (None, True, False, False, None, None, None, None, "full")
|
||||
client._agent_config_key = (None, True, False, False, None, None, None, None, "full", None)
|
||||
|
||||
config = client._get_runnable_config("t1")
|
||||
client._ensure_agent(config)
|
||||
@ -2538,7 +2672,7 @@ class TestScenarioAgentRecreation:
|
||||
|
||||
agents_created = []
|
||||
|
||||
def fake_ensure(config):
|
||||
def fake_ensure(config, **kwargs):
|
||||
key = tuple(config.get("configurable", {}).get(k) for k in ["model_name", "thinking_enabled", "is_plan_mode", "subagent_enabled"])
|
||||
agents_created.append(key)
|
||||
client._agent = agent
|
||||
@ -2551,6 +2685,52 @@ class TestScenarioAgentRecreation:
|
||||
assert len(agents_created) == 2
|
||||
assert agents_created[0] != agents_created[1]
|
||||
|
||||
def test_stream_propagates_trusted_authorization_context(self, client):
|
||||
ai = AIMessage(content="ok", id="ai-1")
|
||||
agent = _make_agent_mock([{"messages": [ai]}])
|
||||
captured: dict = {}
|
||||
|
||||
def fake_ensure(config, *, context):
|
||||
captured.update(context)
|
||||
client._agent = agent
|
||||
|
||||
with patch.object(client, "_ensure_agent", side_effect=fake_ensure):
|
||||
list(
|
||||
client.stream(
|
||||
"hi",
|
||||
thread_id="t1",
|
||||
user_id="u1",
|
||||
user_role="guest",
|
||||
is_internal=True,
|
||||
authz_attributes={"department": "eng"},
|
||||
)
|
||||
)
|
||||
|
||||
assert captured["user_id"] == "u1"
|
||||
assert captured["user_role"] == "guest"
|
||||
assert captured["is_internal"] is True
|
||||
assert captured["authz_attributes"] == {"department": "eng"}
|
||||
assert agent.stream.call_args.kwargs["context"]["user_role"] == "guest"
|
||||
|
||||
def test_stream_uses_effective_user_for_authorization_context(self, client, mock_app_config):
|
||||
mock_app_config.authorization = AuthorizationConfig(
|
||||
enabled=True,
|
||||
provider=AuthorizationProviderConfig(use="unused:Provider"),
|
||||
)
|
||||
client._app_config = mock_app_config
|
||||
agent = _make_agent_mock([{"messages": [AIMessage(content="ok", id="ai-1")]}])
|
||||
captured: dict = {}
|
||||
|
||||
def fake_ensure(config, *, context):
|
||||
captured.update(context)
|
||||
client._agent = agent
|
||||
|
||||
with patch.object(client, "_ensure_agent", side_effect=fake_ensure):
|
||||
list(client.stream("hi", thread_id="t1"))
|
||||
|
||||
assert captured["user_id"] == "test-user-autouse"
|
||||
assert agent.stream.call_args.kwargs["context"]["user_id"] == "test-user-autouse"
|
||||
|
||||
|
||||
class TestScenarioThreadIsolation:
|
||||
"""Scenario: Operations on different threads don't interfere."""
|
||||
|
||||
@ -15,6 +15,7 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
from deerflow.client import DeerFlowClient
|
||||
from deerflow.config.authorization_config import AuthorizationConfig
|
||||
from deerflow.trace_context import DEERFLOW_TRACE_METADATA_KEY, request_trace_context
|
||||
|
||||
|
||||
@ -49,8 +50,9 @@ def _stub_agent_creation(monkeypatch, fake_agent: _FakeAgent) -> dict[str, Any]:
|
||||
"""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _stub_ensure_agent(self, config):
|
||||
def _stub_ensure_agent(self, config, **kwargs):
|
||||
captured["config"] = config
|
||||
captured["context"] = kwargs.get("context")
|
||||
self._agent = fake_agent
|
||||
self._agent_config_key = ("stub",)
|
||||
|
||||
@ -69,6 +71,7 @@ def _make_client(_monkeypatch, *, enhance_enabled: bool = True) -> DeerFlowClien
|
||||
fake_app_config = SimpleNamespace(
|
||||
models=[SimpleNamespace(name="stub-model")],
|
||||
logging=SimpleNamespace(enhance=SimpleNamespace(enabled=enhance_enabled)),
|
||||
authorization=AuthorizationConfig(enabled=False),
|
||||
)
|
||||
client = DeerFlowClient.__new__(DeerFlowClient)
|
||||
client._app_config = fake_app_config
|
||||
|
||||
@ -565,9 +565,12 @@ def test_build_middlewares_passes_explicit_app_config_to_shared_factory(monkeypa
|
||||
def _raise_get_app_config():
|
||||
raise AssertionError("ambient get_app_config() must not be used when app_config is explicit")
|
||||
|
||||
def _fake_build_lead_runtime_middlewares(*, app_config, lazy_init):
|
||||
provider = object()
|
||||
|
||||
def _fake_build_lead_runtime_middlewares(*, app_config, lazy_init, authorization_provider):
|
||||
captured["app_config"] = app_config
|
||||
captured["lazy_init"] = lazy_init
|
||||
captured["authorization_provider"] = authorization_provider
|
||||
return ["base-middleware"]
|
||||
|
||||
monkeypatch.setattr(lead_agent_module, "get_app_config", _raise_get_app_config)
|
||||
@ -593,11 +596,13 @@ def test_build_middlewares_passes_explicit_app_config_to_shared_factory(monkeypa
|
||||
{"configurable": {"is_plan_mode": False, "subagent_enabled": False}},
|
||||
model_name="safe-model",
|
||||
app_config=app_config,
|
||||
authorization_provider=provider,
|
||||
)
|
||||
|
||||
assert captured == {
|
||||
"app_config": app_config,
|
||||
"lazy_init": True,
|
||||
"authorization_provider": provider,
|
||||
"title_app_config": app_config,
|
||||
"memory_config": app_config.memory,
|
||||
}
|
||||
|
||||
@ -494,6 +494,7 @@ class TestModeGating:
|
||||
def test_lead_agent_deduplicates_memory_tools_after_appending(self, monkeypatch):
|
||||
"""Configured tools should not duplicate tool-mode memory tools."""
|
||||
from deerflow.agents.lead_agent import agent as lead_agent_module
|
||||
from deerflow.config.authorization_config import AuthorizationConfig
|
||||
from deerflow.config.memory_config import MemoryConfig
|
||||
|
||||
monkeypatch.setattr(lead_agent_module, "_resolve_model_name", lambda x=None, **kwargs: "default-model")
|
||||
@ -516,6 +517,7 @@ class TestModeGating:
|
||||
skills=SimpleNamespace(deferred_discovery=False, container_path="/tmp/skills"),
|
||||
tool_search=SimpleNamespace(enabled=False, auto_promote_top_k=0),
|
||||
database=SimpleNamespace(checkpoint_channel_mode="full"),
|
||||
authorization=AuthorizationConfig(enabled=False),
|
||||
)
|
||||
|
||||
agent_kwargs = lead_agent_module._make_lead_agent({"configurable": {"agent_name": "test-agent"}}, app_config=app_config)
|
||||
@ -527,6 +529,7 @@ class TestModeGating:
|
||||
def test_lead_agent_preserves_non_memory_duplicate_tool_names(self, monkeypatch):
|
||||
"""Memory-tool collision handling should not drop unrelated duplicate tools."""
|
||||
from deerflow.agents.lead_agent import agent as lead_agent_module
|
||||
from deerflow.config.authorization_config import AuthorizationConfig
|
||||
from deerflow.config.memory_config import MemoryConfig
|
||||
|
||||
monkeypatch.setattr(lead_agent_module, "_resolve_model_name", lambda x=None, **kwargs: "default-model")
|
||||
@ -549,6 +552,7 @@ class TestModeGating:
|
||||
skills=SimpleNamespace(deferred_discovery=False, container_path="/tmp/skills"),
|
||||
tool_search=SimpleNamespace(enabled=False, auto_promote_top_k=0),
|
||||
database=SimpleNamespace(checkpoint_channel_mode="full"),
|
||||
authorization=AuthorizationConfig(enabled=False),
|
||||
)
|
||||
|
||||
agent_kwargs = lead_agent_module._make_lead_agent({"configurable": {"agent_name": "test-agent"}}, app_config=app_config)
|
||||
|
||||
@ -46,7 +46,10 @@ _LANGGRAPH_HAS_ROOT_LINEAGE_STREAM_REGRESSION = Version(package_version("langgra
|
||||
|
||||
|
||||
def _default_app_config():
|
||||
return SimpleNamespace(tool_search=SimpleNamespace(enabled=False))
|
||||
return SimpleNamespace(
|
||||
tool_search=SimpleNamespace(enabled=False),
|
||||
authorization=SimpleNamespace(enabled=False),
|
||||
)
|
||||
|
||||
|
||||
def _patch_default_get_app_config(executor_module):
|
||||
@ -312,10 +315,13 @@ class TestAgentConstruction:
|
||||
app_config=app_config,
|
||||
parent_model="parent-model",
|
||||
)
|
||||
provider = object()
|
||||
executor._authz_provider = provider
|
||||
|
||||
result = executor._create_agent()
|
||||
|
||||
assert result is agent
|
||||
assert captured["middlewares"]["authorization_provider"] is provider
|
||||
assert captured["model"] == {
|
||||
"name": "parent-model",
|
||||
"thinking_enabled": False,
|
||||
@ -332,6 +338,7 @@ class TestAgentConstruction:
|
||||
"lazy_init": True,
|
||||
"deferred_setup": None,
|
||||
"agent_name": "test-agent",
|
||||
"authorization_provider": provider,
|
||||
}
|
||||
assert captured["agent"]["model"] is model
|
||||
assert captured["agent"]["middleware"] is middlewares
|
||||
@ -599,7 +606,14 @@ class TestAgentConstruction:
|
||||
"get_or_new_user_skill_storage",
|
||||
lambda user_id, *, app_config=None: SimpleNamespace(load_skills=lambda *, enabled_only: []),
|
||||
)
|
||||
monkeypatch.setattr(executor_module, "get_app_config", lambda: SimpleNamespace(tool_search=SimpleNamespace(enabled=True)))
|
||||
monkeypatch.setattr(
|
||||
executor_module,
|
||||
"get_app_config",
|
||||
lambda: SimpleNamespace(
|
||||
tool_search=SimpleNamespace(enabled=True),
|
||||
authorization=SimpleNamespace(enabled=False),
|
||||
),
|
||||
)
|
||||
|
||||
@as_tool
|
||||
def mcp_calc(expression: str) -> str:
|
||||
@ -640,7 +654,14 @@ class TestAgentConstruction:
|
||||
"get_or_new_user_skill_storage",
|
||||
lambda user_id, *, app_config=None: SimpleNamespace(load_skills=lambda *, enabled_only: []),
|
||||
)
|
||||
monkeypatch.setattr(executor_module, "get_app_config", lambda: SimpleNamespace(tool_search=SimpleNamespace(enabled=False)))
|
||||
monkeypatch.setattr(
|
||||
executor_module,
|
||||
"get_app_config",
|
||||
lambda: SimpleNamespace(
|
||||
tool_search=SimpleNamespace(enabled=False),
|
||||
authorization=SimpleNamespace(enabled=False),
|
||||
),
|
||||
)
|
||||
|
||||
@as_tool
|
||||
def mcp_calc(expression: str) -> str:
|
||||
@ -655,6 +676,47 @@ class TestAgentConstruction:
|
||||
assert deferred_setup.deferred_names == frozenset()
|
||||
assert "<available-deferred-tools>" not in state["messages"][0].content
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_build_initial_state_applies_authorization_before_deferral(
|
||||
self,
|
||||
classes,
|
||||
base_config,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
from deerflow.config.authorization_config import AuthorizationConfig, AuthorizationProviderConfig
|
||||
|
||||
SubagentExecutor = classes["SubagentExecutor"]
|
||||
monkeypatch.setattr(
|
||||
sys.modules["deerflow.skills.storage"],
|
||||
"get_or_new_skill_storage",
|
||||
lambda *, app_config=None: SimpleNamespace(load_skills=lambda *, enabled_only: []),
|
||||
)
|
||||
app_config = SimpleNamespace(
|
||||
authorization=AuthorizationConfig(
|
||||
enabled=True,
|
||||
provider=AuthorizationProviderConfig(
|
||||
use="deerflow.authz.rbac:RbacAuthorizationProvider",
|
||||
config={"roles": {"user": {"tools": {"allow": ["safe_tool"]}}}},
|
||||
),
|
||||
),
|
||||
models=[SimpleNamespace(name="test-model")],
|
||||
tool_search=SimpleNamespace(enabled=False),
|
||||
)
|
||||
executor = SubagentExecutor(
|
||||
config=base_config,
|
||||
tools=[NamedTool("safe_tool"), NamedTool("denied_tool")],
|
||||
app_config=app_config,
|
||||
parent_model="test-model",
|
||||
user_role="user",
|
||||
thread_id="test-thread",
|
||||
)
|
||||
|
||||
_state, final_tools, deferred_setup = await executor._build_initial_state("Do the task")
|
||||
|
||||
assert [tool.name for tool in final_tools] == ["safe_tool"]
|
||||
assert deferred_setup.deferred_names == frozenset()
|
||||
assert executor._authz_provider is not None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_build_initial_state_deferral_respects_tool_policy_and_tool_search_is_infra(
|
||||
self,
|
||||
@ -685,7 +747,14 @@ class TestAgentConstruction:
|
||||
"get_or_new_user_skill_storage",
|
||||
lambda user_id, *, app_config=None: SimpleNamespace(load_skills=lambda *, enabled_only: []),
|
||||
)
|
||||
monkeypatch.setattr(executor_module, "get_app_config", lambda: SimpleNamespace(tool_search=SimpleNamespace(enabled=True)))
|
||||
monkeypatch.setattr(
|
||||
executor_module,
|
||||
"get_app_config",
|
||||
lambda: SimpleNamespace(
|
||||
tool_search=SimpleNamespace(enabled=True),
|
||||
authorization=SimpleNamespace(enabled=False),
|
||||
),
|
||||
)
|
||||
|
||||
@as_tool
|
||||
def active_tool(x: str) -> str:
|
||||
|
||||
@ -2124,9 +2124,25 @@ run_ownership:
|
||||
# Authorization Configuration
|
||||
# ============================================================================
|
||||
# Fine-grained resource authorization (RBAC and beyond). Disabled by default;
|
||||
# every authenticated user has access to all resources. Schema is present but
|
||||
# inert; the built-in RBAC provider and runtime wiring arrive in subsequent phases.
|
||||
# every authenticated user has access to all resources.
|
||||
# See RFC: https://github.com/bytedance/deer-flow/issues/4063
|
||||
#
|
||||
# authorization:
|
||||
# enabled: true
|
||||
# fail_closed: true # block on provider error / unresolved identity
|
||||
# default_role: user # applied when user_role is None; built-in RBAC requires this role below
|
||||
# provider:
|
||||
# use: deerflow.authz.rbac:RbacAuthorizationProvider
|
||||
# config:
|
||||
# # A known role with no `tools` policy is unrestricted for tools.
|
||||
# # Define `tools` for every role whose tool access should be constrained.
|
||||
# roles:
|
||||
# admin:
|
||||
# tools: {allow: "*"}
|
||||
# user:
|
||||
# tools: {allow: "*", deny: ["update_agent"]}
|
||||
# guest:
|
||||
# tools: {allow: ["web_search", "read_file"]}
|
||||
authorization:
|
||||
enabled: false
|
||||
|
||||
|
||||
@ -248,6 +248,46 @@ Phase 1 最低验证要求:
|
||||
- **兼容性:** 只拒绝不符合 `AuthzRequest` / `filter_resources` 类型契约的运行时
|
||||
输入;Phase 1A-2 仍未接入运行时,`authorization.enabled: false` 行为不变。
|
||||
|
||||
### 2026-07-22 — Phase 1B / 工具授权执行接入
|
||||
|
||||
- **背景:** Phase 1A 完成了 Principal 链路、RBAC provider 和 factory。
|
||||
Phase 1B 将 provider 接入 Layer 1(组装时过滤)和 Layer 2(执行时拦截)。
|
||||
- **决策:** `apply_tool_authorization()` 是 Layer 1 的统一入口,组合 provider
|
||||
解析、Principal 构建和 `filter_tools_by_authorization` 过滤。disabled 时返回
|
||||
原始工具和 `None`。
|
||||
- **决策:** Layer 1 过滤在 `assemble_deferred_tools` 之前执行,覆盖三条路径:
|
||||
lead agent(bootstrap + default)、subagent、embedded client。
|
||||
- **决策:** Layer 2 通过 `GuardrailAuthorizationAdapter` 复用 `GuardrailMiddleware`,
|
||||
authorization middleware 在显式 guardrail 之前(外层),两者独立运行。
|
||||
- **决策:** Layer 1 和 Layer 2 尝试共享同一个 provider 实例("resolve once per
|
||||
build")。subagent 通过 `_authz_provider` 属性传递。
|
||||
- **决策:** Embedded client `_agent_config_key` 加入 `user_role` 和 `is_internal`
|
||||
作为 cache key,角色变化时强制重建 agent。
|
||||
- **兼容性:** `authorization.enabled: false` 时工具集合和执行决策完全不变。
|
||||
- **延期:** Models/Skills/Sandbox 权限(Phase 2+);route-level 迁移。
|
||||
|
||||
#### Phase 1B review 收口
|
||||
|
||||
- Layer 1 的候选集合必须包含本次 build 最终可能暴露给模型的全部业务工具;
|
||||
`describe_skill` 和 memory tools 因此在授权过滤前加入,过滤后才进行 deferred
|
||||
assembly。框架生成的 `tool_search` 仍是受已过滤 catalog 约束的基础设施工具。
|
||||
- lead、bootstrap、native subagent 和 embedded client 都把 Layer 1 解析出的同一
|
||||
provider 实例传给 Layer 2,禁止在 middleware 构建时再次解析 provider。
|
||||
- `tool_search` 仅在当前 build 确实生成了 deferred catalog 时作为基础设施工具跳过
|
||||
authorization adapter 的第二次 provider 调用;catalog 已由 Layer 1 过滤。显式配置的
|
||||
guardrail 仍会检查它,没有 deferred setup 的普通同名工具也不获得豁免。
|
||||
- 内置 RBAC provider 在解析时校验 `authorization.default_role` 属于已配置角色,配置
|
||||
错误直接阻止 agent 构建,不再表现为难以诊断的空工具集合。
|
||||
- `DeerFlowClient.stream()` 的调用方属于可信进程内边界,可通过关键字参数传入与
|
||||
Gateway runtime context 相同的授权身份字段;这些字段同时进入真实执行 context。
|
||||
agent cache key 使用完整 Principal(包括 user/channel/oauth/internal/attributes),
|
||||
并深拷贝嵌套 attributes,防止调用方原地修改身份数据后复用旧工具集合。
|
||||
- disabled 模式仍在 Layer 1 候选阶段包含 `describe_skill` / memory tools,但 deferred
|
||||
assembly 后恢复原有顺序(业务工具、`tool_search`、late framework tools)。
|
||||
- 回归测试必须经过真实 lead/bootstrap、subagent 和 embedded 组装函数,不能只测试
|
||||
`filter_tools_by_authorization()` helper;同时断言被拒绝工具不在最终 bound tools 中,
|
||||
且 Layer 2 收到的 provider 与 Layer 1 为同一对象。
|
||||
|
||||
### 新记录模板
|
||||
|
||||
```markdown
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user