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:
hataa 2026-07-23 22:51:35 +08:00 committed by GitHub
parent 6aad680596
commit 7857fa0cce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1330 additions and 45 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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,
),

View File

@ -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:

View File

@ -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",
]

View File

@ -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)

View 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]

View File

@ -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)

View File

@ -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

View 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

View File

@ -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:

View File

@ -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

View 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

View File

@ -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": "*"}}}},

View 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

View File

@ -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."""

View File

@ -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

View File

@ -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,
}

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -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 agentbootstrap + 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