hataa 7857fa0cce
feat(authz): enforce tool authorization at assembly and runtime (#4370)
* feat(authz): enforce tool authorization at assembly and runtime

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

---------

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

139 lines
5.9 KiB
Python

"""Adapter that presents an AuthorizationProvider as a GuardrailProvider.
This lets the existing :class:`~deerflow.guardrails.middleware.GuardrailMiddleware`
enforce :class:`~deerflow.authz.provider.AuthorizationProvider` decisions at
tool-call time — no new middleware class required (see RFC §6.1).
The adapter maps :class:`~deerflow.guardrails.provider.GuardrailRequest`
fields to :class:`~deerflow.authz.provider.AuthzRequest` fields, calls the
authorization provider, and converts the :class:`~deerflow.authz.provider.AuthzDecision`
back to a :class:`~deerflow.guardrails.provider.GuardrailDecision`.
Principal construction delegates to
:func:`~deerflow.authz.principal.build_principal_from_context` so Layer 1
(tool assembly) and Layer 2 (this adapter) share a single identity builder
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
class GuardrailAuthorizationAdapter:
"""Adapt an :class:`AuthorizationProvider` to the ``GuardrailProvider`` Protocol.
``resource_type`` and ``action`` default to ``"tool"`` / ``"call"``,
which is correct for the tool-execution path. A different resource/action
pair can be injected if the adapter is reused outside the tool path.
Args:
provider: The authorization provider to delegate decisions to.
default_role: Role used when ``user_role`` is absent or empty in the
runtime context. Must be passed by Phase 1B wiring from
``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"
def __init__(
self,
provider: AuthorizationProvider,
*,
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."""
principal = build_principal_from_context(
{
"user_id": gr.user_id,
"user_role": gr.user_role,
"oauth_provider": gr.oauth_provider,
"oauth_id": gr.oauth_id,
"channel_user_id": gr.channel_user_id,
"is_internal": gr.is_internal,
"authz_attributes": gr.authz_attributes,
},
default_role=self._default_role,
)
return AuthzRequest(
principal=principal,
resource=self._resource_type,
action=self._action,
target=gr.tool_name,
context={
"thread_id": gr.thread_id,
"run_id": gr.run_id,
"tool_call_id": gr.tool_call_id,
"tool_input": gr.tool_input,
"is_subagent": gr.is_subagent,
"agent_id": gr.agent_id,
"timestamp": gr.timestamp,
},
)
@staticmethod
def _to_guardrail(d: AuthzDecision) -> GuardrailDecision:
"""Convert an authorization decision to a guardrail decision."""
return GuardrailDecision(
allow=d.allow,
reasons=[GuardrailReason(code=r.code, message=r.message) for r in d.reasons],
policy_id=d.policy_id,
metadata=d.metadata,
)
def evaluate(self, request: GuardrailRequest) -> GuardrailDecision:
"""Synchronous evaluation: delegate to ``provider.authorize``.
Provider exceptions are intentionally allowed to propagate. The
adapter is consumed by :class:`~deerflow.guardrails.middleware.GuardrailMiddleware`,
whose ``wrap_tool_call`` / ``awrap_tool_call`` already applies
fail-closed semantics based on its ``fail_closed`` parameter
(backed by ``AuthorizationConfig.fail_closed``). Catching exceptions
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)
async def aevaluate(self, request: GuardrailRequest) -> GuardrailDecision:
"""Async evaluation: delegate to ``provider.aauthorize``.
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)