From 7857fa0cced9132d125dee41da39e96b22d7672f Mon Sep 17 00:00:00 2001 From: hataa <79907651+hata33@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:51:35 +0800 Subject: [PATCH] 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 --- README.md | 2 + backend/AGENTS.md | 4 +- .../deerflow/agents/lead_agent/agent.py | 54 ++- .../tool_error_handling_middleware.py | 43 +- .../harness/deerflow/authz/__init__.py | 4 + .../harness/deerflow/authz/adapter.py | 22 + .../harness/deerflow/authz/enforcement.py | 42 ++ .../packages/harness/deerflow/authz/rbac.py | 11 +- .../harness/deerflow/authz/runtime.py | 8 + .../harness/deerflow/authz/tool_filter.py | 72 ++++ backend/packages/harness/deerflow/client.py | 102 ++++- .../harness/deerflow/subagents/executor.py | 24 ++ .../tests/test_authorization_enforcement.py | 390 ++++++++++++++++++ backend/tests/test_authorization_runtime.py | 15 + .../tests/test_authorization_tool_filter.py | 245 +++++++++++ backend/tests/test_client.py | 184 ++++++++- .../tests/test_client_langfuse_metadata.py | 5 +- .../tests/test_lead_agent_model_resolution.py | 7 +- backend/tests/test_memory_tools.py | 4 + backend/tests/test_subagent_executor.py | 77 +++- config.example.yaml | 20 +- ...able-authorization-implementation-notes.md | 40 ++ 22 files changed, 1330 insertions(+), 45 deletions(-) create mode 100644 backend/packages/harness/deerflow/authz/enforcement.py create mode 100644 backend/packages/harness/deerflow/authz/tool_filter.py create mode 100644 backend/tests/test_authorization_enforcement.py create mode 100644 backend/tests/test_authorization_tool_filter.py diff --git a/README.md b/README.md index d04bae51c..c73873787 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 0f84caec7..9e94d11fd 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -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. diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index 3275ff6dd..5242696d7 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -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 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, ), diff --git a/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py index d82dcba4e..9f47d4318 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py @@ -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: diff --git a/backend/packages/harness/deerflow/authz/__init__.py b/backend/packages/harness/deerflow/authz/__init__.py index b0218e620..795ecdfee 100644 --- a/backend/packages/harness/deerflow/authz/__init__.py +++ b/backend/packages/harness/deerflow/authz/__init__.py @@ -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", ] diff --git a/backend/packages/harness/deerflow/authz/adapter.py b/backend/packages/harness/deerflow/authz/adapter.py index 122059924..e42d1db6f 100644 --- a/backend/packages/harness/deerflow/authz/adapter.py +++ b/backend/packages/harness/deerflow/authz/adapter.py @@ -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) diff --git a/backend/packages/harness/deerflow/authz/enforcement.py b/backend/packages/harness/deerflow/authz/enforcement.py new file mode 100644 index 000000000..93714c4d6 --- /dev/null +++ b/backend/packages/harness/deerflow/authz/enforcement.py @@ -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] diff --git a/backend/packages/harness/deerflow/authz/rbac.py b/backend/packages/harness/deerflow/authz/rbac.py index ff315b2d3..678a689e3 100644 --- a/backend/packages/harness/deerflow/authz/rbac.py +++ b/backend/packages/harness/deerflow/authz/rbac.py @@ -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) diff --git a/backend/packages/harness/deerflow/authz/runtime.py b/backend/packages/harness/deerflow/authz/runtime.py index 8fb4062d7..b0f775668 100644 --- a/backend/packages/harness/deerflow/authz/runtime.py +++ b/backend/packages/harness/deerflow/authz/runtime.py @@ -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 diff --git a/backend/packages/harness/deerflow/authz/tool_filter.py b/backend/packages/harness/deerflow/authz/tool_filter.py new file mode 100644 index 000000000..8dbbd68e1 --- /dev/null +++ b/backend/packages/harness/deerflow/authz/tool_filter.py @@ -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 diff --git a/backend/packages/harness/deerflow/client.py b/backend/packages/harness/deerflow/client.py index 8d2b3db37..757ce6ee7 100644 --- a/backend/packages/harness/deerflow/client.py +++ b/backend/packages/harness/deerflow/client.py @@ -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: diff --git a/backend/packages/harness/deerflow/subagents/executor.py b/backend/packages/harness/deerflow/subagents/executor.py index f6e12a0d6..9a909ff70 100644 --- a/backend/packages/harness/deerflow/subagents/executor.py +++ b/backend/packages/harness/deerflow/subagents/executor.py @@ -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 diff --git a/backend/tests/test_authorization_enforcement.py b/backend/tests/test_authorization_enforcement.py new file mode 100644 index 000000000..b206766ec --- /dev/null +++ b/backend/tests/test_authorization_enforcement.py @@ -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 diff --git a/backend/tests/test_authorization_runtime.py b/backend/tests/test_authorization_runtime.py index 159bdf0e6..96219be11 100644 --- a/backend/tests/test_authorization_runtime.py +++ b/backend/tests/test_authorization_runtime.py @@ -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": "*"}}}}, diff --git a/backend/tests/test_authorization_tool_filter.py b/backend/tests/test_authorization_tool_filter.py new file mode 100644 index 000000000..559470614 --- /dev/null +++ b/backend/tests/test_authorization_tool_filter.py @@ -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 diff --git a/backend/tests/test_client.py b/backend/tests/test_client.py index fcb069c29..fab6187c2 100644 --- a/backend/tests/test_client.py +++ b/backend/tests/test_client.py @@ -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.""" diff --git a/backend/tests/test_client_langfuse_metadata.py b/backend/tests/test_client_langfuse_metadata.py index 595a2ab9d..51daac199 100644 --- a/backend/tests/test_client_langfuse_metadata.py +++ b/backend/tests/test_client_langfuse_metadata.py @@ -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 diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py index 80d44207e..cf51f2ba7 100644 --- a/backend/tests/test_lead_agent_model_resolution.py +++ b/backend/tests/test_lead_agent_model_resolution.py @@ -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, } diff --git a/backend/tests/test_memory_tools.py b/backend/tests/test_memory_tools.py index d10b47426..2eb5789ba 100644 --- a/backend/tests/test_memory_tools.py +++ b/backend/tests/test_memory_tools.py @@ -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) diff --git a/backend/tests/test_subagent_executor.py b/backend/tests/test_subagent_executor.py index 8392d402c..ee3694730 100644 --- a/backend/tests/test_subagent_executor.py +++ b/backend/tests/test_subagent_executor.py @@ -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 "" 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: diff --git a/config.example.yaml b/config.example.yaml index 4754262d9..3df21fb18 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 diff --git a/docs/plans/2026-07-10-pluggable-authorization-implementation-notes.md b/docs/plans/2026-07-10-pluggable-authorization-implementation-notes.md index 11b1208eb..634338db4 100644 --- a/docs/plans/2026-07-10-pluggable-authorization-implementation-notes.md +++ b/docs/plans/2026-07-10-pluggable-authorization-implementation-notes.md @@ -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