mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-25 23:48:00 +00:00
feat(authz): add pluggable AuthorizationProvider protocol and config scaffolding (Phase 0, #4063) (#4127)
Phase 0 of the RFC in #4063: scaffolding only, zero behavior change. New deerflow/authz/ package (sibling to deerflow/guardrails/): - AuthorizationProvider Protocol: authorize() + aauthorize() + filter_resources() - Principal/AuthzRequest/AuthzDecision/AuthzReason dataclasses - GuardrailAuthorizationAdapter: bridges AuthorizationProvider → GuardrailProvider so existing GuardrailMiddleware can enforce authz decisions without a new middleware New authorization config section (default enabled: false): - AuthorizationConfig wired into AppConfig alongside guardrails - Singleton load/reset mirrors GuardrailsConfig pattern - config.example.yaml documents the RBAC provider schema 29 tests covering protocol conformance, dataclass construction, adapter request/decision mapping, and config singleton behavior. Per RFC #4063 Phase 0 (foundations). Layer 1/2 wiring and Principal builder in services.py deferred to Phase 1.
This commit is contained in:
parent
f3e4e5f8f0
commit
1300c6d36b
13
backend/packages/harness/deerflow/authz/__init__.py
Normal file
13
backend/packages/harness/deerflow/authz/__init__.py
Normal file
@ -0,0 +1,13 @@
|
||||
"""Pluggable fine-grained authorization (resource-level RBAC and beyond)."""
|
||||
|
||||
from deerflow.authz.adapter import GuardrailAuthorizationAdapter
|
||||
from deerflow.authz.provider import AuthorizationProvider, AuthzDecision, AuthzReason, AuthzRequest, Principal
|
||||
|
||||
__all__ = [
|
||||
"AuthzDecision",
|
||||
"AuthzReason",
|
||||
"AuthzRequest",
|
||||
"AuthorizationProvider",
|
||||
"GuardrailAuthorizationAdapter",
|
||||
"Principal",
|
||||
]
|
||||
101
backend/packages/harness/deerflow/authz/adapter.py
Normal file
101
backend/packages/harness/deerflow/authz/adapter.py
Normal file
@ -0,0 +1,101 @@
|
||||
"""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`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.authz.provider import AuthorizationProvider, AuthzDecision, AuthzRequest, Principal
|
||||
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.
|
||||
|
||||
.. note::
|
||||
|
||||
``Principal.is_internal`` is not populated by this adapter — the
|
||||
correct signal (``auth_source == AUTH_SOURCE_INTERNAL``) lives on
|
||||
``request.state``, not on :class:`GuardrailRequest`. The field
|
||||
retains its dataclass default (``False``) until Phase 1 threads it
|
||||
into run context, so both layers derive it from one source.
|
||||
"""
|
||||
|
||||
name = "authorization"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: AuthorizationProvider,
|
||||
*,
|
||||
resource_type: str = "tool",
|
||||
action: str = "call",
|
||||
) -> None:
|
||||
self._provider = provider
|
||||
self._resource_type = resource_type
|
||||
self._action = action
|
||||
|
||||
def _to_authz(self, gr: GuardrailRequest) -> AuthzRequest:
|
||||
"""Map a guardrail request to an authorization request."""
|
||||
return AuthzRequest(
|
||||
principal=Principal(
|
||||
user_id=gr.user_id,
|
||||
role=gr.user_role,
|
||||
oauth_provider=gr.oauth_provider,
|
||||
oauth_id=gr.oauth_id,
|
||||
),
|
||||
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.
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
decision = await self._provider.aauthorize(self._to_authz(request))
|
||||
return self._to_guardrail(decision)
|
||||
121
backend/packages/harness/deerflow/authz/provider.py
Normal file
121
backend/packages/harness/deerflow/authz/provider.py
Normal file
@ -0,0 +1,121 @@
|
||||
"""AuthorizationProvider protocol and data structures for fine-grained resource authorization.
|
||||
|
||||
This is the policy brain for resource-level authorization (RBAC and beyond),
|
||||
deliberately kept as a sibling to :mod:`deerflow.guardrails` rather than folded
|
||||
into it. PR #3665 (which added ``user_role``/``user_id`` to
|
||||
``GuardrailRequest``) explicitly scoped guardrails to *execution-time* checks
|
||||
only — *"保持 Guardrail 的职责边界不变:不新增 policy engine、RBAC 系统、
|
||||
governance 子系统"*. This module is the RBAC brain that #3665 deferred.
|
||||
|
||||
The provider is enforced at **two layers** from one policy:
|
||||
|
||||
1. **Assembly-time capability filter** — removes tools a role can never use
|
||||
*before* they are bound to the agent, so the model never sees them and
|
||||
``tool_search`` can never promote them back (fail-closed).
|
||||
2. **Run-time execution deny** — reuses :class:`~deerflow.guardrails.middleware.GuardrailMiddleware`
|
||||
via a thin adapter (see :mod:`deerflow.authz.adapter`), catching dynamic
|
||||
resources and argument-based restrictions.
|
||||
|
||||
See ``docs/plans/2026-07-10-pluggable-authorization-rfc.md`` (issue #4063) for
|
||||
the full design rationale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass
|
||||
class Principal:
|
||||
"""The actor. Built once per run from ``request.state.user`` + context.
|
||||
|
||||
Identity fields mirror what ``inject_authenticated_user_context``
|
||||
(``app/gateway/services.py``) already stamps into the run context, so the
|
||||
provider sees a single resolved actor rather than re-deriving it per call.
|
||||
"""
|
||||
|
||||
user_id: str | None = None
|
||||
role: str | None = None
|
||||
oauth_provider: str | None = None
|
||||
oauth_id: str | None = None
|
||||
channel_user_id: str | None = None
|
||||
is_internal: bool = False
|
||||
attributes: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthzRequest:
|
||||
"""Context passed to the provider for each authorization check."""
|
||||
|
||||
principal: Principal
|
||||
resource: str
|
||||
"""Resource type, e.g. ``"tool"``, ``"model"``, ``"skill"``, ``"sandbox"``, ``"mcp_server"``, ``"route"``."""
|
||||
|
||||
action: str
|
||||
"""Action on the resource, e.g. ``"call"``, ``"list"``, ``"use"``, ``"activate"``, ``"execute"``, ``"read"``, ``"write"``."""
|
||||
|
||||
target: str
|
||||
"""Resource identifier: tool name, model name, skill name, ``"route:threads:read"``, etc."""
|
||||
|
||||
context: dict[str, Any] = field(default_factory=dict)
|
||||
"""Additional context: ``thread_id``, ``run_id``, ``tool_call_id``, ``tool_input``, ``is_subagent``, etc."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthzReason:
|
||||
"""Structured reason for an allow/deny decision."""
|
||||
|
||||
code: str
|
||||
message: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthzDecision:
|
||||
"""Provider's allow/deny verdict."""
|
||||
|
||||
allow: bool
|
||||
reasons: list[AuthzReason] = field(default_factory=list)
|
||||
policy_id: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AuthorizationProvider(Protocol):
|
||||
"""Contract for pluggable fine-grained authorization.
|
||||
|
||||
Any class with these methods works - no base class required.
|
||||
Providers are loaded by class path via ``resolve_variable()``, the same
|
||||
mechanism DeerFlow uses for models, tools, sandbox, and guardrails.
|
||||
|
||||
``resource``, ``action``, and ``target`` are free-form strings (not an
|
||||
enum) so new resource types and provider-specific resources need no schema
|
||||
change. The built-in RBAC provider interprets them; custom providers
|
||||
define their own.
|
||||
"""
|
||||
|
||||
name: str
|
||||
|
||||
def authorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
"""Per-call decision. Feeds Layer 2 (execution) and route checks."""
|
||||
...
|
||||
|
||||
async def aauthorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
"""Async variant."""
|
||||
...
|
||||
|
||||
def filter_resources(
|
||||
self,
|
||||
principal: Principal,
|
||||
resource_type: str,
|
||||
candidates: list[str],
|
||||
) -> list[str]:
|
||||
"""Layer 1: batch visibility filter at assembly time.
|
||||
|
||||
Returns the subset of *candidates* the principal is allowed to see.
|
||||
This is a required method — providers that do not have a static
|
||||
role→resource map should delegate to :meth:`authorize` per item and
|
||||
return only the allowed subset. Providers with a static map can
|
||||
override this for O(1) filtering and fail-closed visibility.
|
||||
"""
|
||||
...
|
||||
@ -13,6 +13,7 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator
|
||||
from deerflow.config.acp_config import ACPAgentConfig, load_acp_config_from_dict
|
||||
from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_config_from_dict
|
||||
from deerflow.config.auth_config import AuthAppConfig
|
||||
from deerflow.config.authorization_config import AuthorizationConfig, load_authorization_config_from_dict
|
||||
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
|
||||
from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict
|
||||
from deerflow.config.database_config import DatabaseConfig
|
||||
@ -169,6 +170,7 @@ class AppConfig(BaseModel):
|
||||
acp_agents: dict[str, ACPAgentConfig] = Field(default_factory=dict, description="ACP-compatible agent configuration")
|
||||
subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration")
|
||||
guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration")
|
||||
authorization: AuthorizationConfig = Field(default_factory=AuthorizationConfig, description="Fine-grained resource authorization configuration (RBAC and beyond)")
|
||||
input_polish: InputPolishConfig = Field(default_factory=InputPolishConfig, description="Pre-send input polishing configuration.")
|
||||
suggestions: SuggestionsConfig = Field(default_factory=SuggestionsConfig, description="Follow-up suggestions configuration.")
|
||||
circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig, description="LLM circuit breaker configuration")
|
||||
@ -353,6 +355,7 @@ class AppConfig(BaseModel):
|
||||
load_subagents_config_from_dict(config.subagents.model_dump())
|
||||
load_tool_search_config_from_dict(config.tool_search.model_dump())
|
||||
load_guardrails_config_from_dict(config.guardrails.model_dump())
|
||||
load_authorization_config_from_dict(config.authorization.model_dump())
|
||||
load_checkpointer_config_from_dict(config.checkpointer.model_dump() if config.checkpointer is not None else None)
|
||||
load_stream_bridge_config_from_dict(config.stream_bridge.model_dump() if config.stream_bridge is not None else None)
|
||||
load_acp_config_from_dict({name: agent.model_dump() for name, agent in acp_agents.items()})
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
"""Configuration for fine-grained resource authorization.
|
||||
|
||||
When enabled, a pluggable :class:`~deerflow.authz.provider.AuthorizationProvider`
|
||||
becomes the policy brain for resource-level authorization, enforced at two
|
||||
layers: assembly-time capability filtering (tools the agent can never see) and
|
||||
run-time execution deny (reuses :class:`~deerflow.guardrails.middleware.GuardrailMiddleware`
|
||||
via an adapter). Default ``enabled: false`` preserves today's behavior where
|
||||
every authenticated user has access to all tools, models, skills, and sandbox.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AuthorizationProviderConfig(BaseModel):
|
||||
"""Configuration for an authorization provider."""
|
||||
|
||||
use: str = Field(description="Class path (e.g. 'deerflow.authz.rbac:RbacAuthorizationProvider')")
|
||||
config: dict = Field(default_factory=dict, description="Provider-specific settings passed as kwargs")
|
||||
|
||||
|
||||
class AuthorizationConfig(BaseModel):
|
||||
"""Configuration for fine-grained resource authorization.
|
||||
|
||||
Mirrors :class:`~deerflow.config.guardrails_config.GuardrailsConfig` in
|
||||
shape: a provider loaded by class path, a fail-closed default, and a
|
||||
live-reloadable singleton.
|
||||
"""
|
||||
|
||||
enabled: bool = Field(default=False, description="Enable fine-grained authorization")
|
||||
fail_closed: bool = Field(default=True, description="Block access if the provider errors or identity is unresolved")
|
||||
default_role: str = Field(default="user", description="Role applied when user_role is None (e.g. unbound IM channels)")
|
||||
provider: AuthorizationProviderConfig | None = Field(default=None, description="Authorization provider configuration")
|
||||
|
||||
|
||||
_authorization_config: AuthorizationConfig | None = None
|
||||
|
||||
|
||||
def get_authorization_config() -> AuthorizationConfig:
|
||||
"""Get the authorization config, returning defaults if not loaded."""
|
||||
global _authorization_config
|
||||
if _authorization_config is None:
|
||||
_authorization_config = AuthorizationConfig()
|
||||
return _authorization_config
|
||||
|
||||
|
||||
def load_authorization_config_from_dict(data: dict) -> AuthorizationConfig:
|
||||
"""Load authorization config from a dict (called during AppConfig loading)."""
|
||||
global _authorization_config
|
||||
_authorization_config = AuthorizationConfig.model_validate(data)
|
||||
return _authorization_config
|
||||
|
||||
|
||||
def reset_authorization_config() -> None:
|
||||
"""Reset the cached config instance. Used in tests to prevent singleton leaks."""
|
||||
global _authorization_config
|
||||
_authorization_config = None
|
||||
540
backend/tests/test_authorization_provider.py
Normal file
540
backend/tests/test_authorization_provider.py
Normal file
@ -0,0 +1,540 @@
|
||||
"""Tests for the authorization provider protocol, adapter, and configuration.
|
||||
|
||||
Phase 0 covers scaffolding only (no behavior change at ``enabled: false``).
|
||||
These tests verify:
|
||||
- Protocol conformance and ``@runtime_checkable`` isinstance checks.
|
||||
- Principal / AuthzRequest / AuthzDecision dataclass construction.
|
||||
- GuardrailAuthorizationAdapter request mapping and decision conversion.
|
||||
- AuthorizationConfig defaults, singleton load/reset, and AppConfig wiring.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.authz.adapter import GuardrailAuthorizationAdapter
|
||||
from deerflow.authz.provider import (
|
||||
AuthorizationProvider,
|
||||
AuthzDecision,
|
||||
AuthzReason,
|
||||
AuthzRequest,
|
||||
Principal,
|
||||
)
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.authorization_config import (
|
||||
AuthorizationConfig,
|
||||
get_authorization_config,
|
||||
load_authorization_config_from_dict,
|
||||
reset_authorization_config,
|
||||
)
|
||||
from deerflow.config.sandbox_config import SandboxConfig
|
||||
from deerflow.guardrails.provider import GuardrailDecision, GuardrailProvider, GuardrailRequest
|
||||
|
||||
# --- Test providers ---
|
||||
|
||||
|
||||
class _AllowAllProvider:
|
||||
"""Provider that allows everything."""
|
||||
|
||||
name = "allow-all"
|
||||
|
||||
def authorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
return AuthzDecision(allow=True, reasons=[AuthzReason(code="test.allowed", message="allow-all")])
|
||||
|
||||
async def aauthorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
return self.authorize(request)
|
||||
|
||||
def filter_resources(self, principal: Principal, resource_type: str, candidates: list[str]) -> list[str]:
|
||||
return list(candidates)
|
||||
|
||||
|
||||
class _DenyAllProvider:
|
||||
"""Provider that denies everything."""
|
||||
|
||||
name = "deny-all"
|
||||
|
||||
def authorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
return AuthzDecision(allow=False, reasons=[AuthzReason(code="test.denied", message="deny-all")], policy_id="test.deny.v1")
|
||||
|
||||
async def aauthorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
return self.authorize(request)
|
||||
|
||||
def filter_resources(self, principal: Principal, resource_type: str, candidates: list[str]) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
class _FilterByDenylistProvider:
|
||||
"""Provider whose filter_resources removes a denylist, regardless of authorize()."""
|
||||
|
||||
name = "denylist-filter"
|
||||
|
||||
def __init__(self, *, denied: list[str] | None = None):
|
||||
self._denied = set(denied) if denied else set()
|
||||
|
||||
def authorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
if request.target in self._denied:
|
||||
return AuthzDecision(allow=False, reasons=[AuthzReason(code="test.denied", message=f"'{request.target}' is denied")])
|
||||
return AuthzDecision(allow=True, reasons=[AuthzReason(code="test.allowed")])
|
||||
|
||||
async def aauthorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
return self.authorize(request)
|
||||
|
||||
def filter_resources(self, principal: Principal, resource_type: str, candidates: list[str]) -> list[str]:
|
||||
return [c for c in candidates if c not in self._denied]
|
||||
|
||||
|
||||
# --- Protocol conformance ---
|
||||
|
||||
|
||||
class TestProtocolConformance:
|
||||
"""Verify the @runtime_checkable Protocol recognizes concrete providers."""
|
||||
|
||||
def test_allow_all_is_authorization_provider(self):
|
||||
assert isinstance(_AllowAllProvider(), AuthorizationProvider)
|
||||
|
||||
def test_deny_all_is_authorization_provider(self):
|
||||
assert isinstance(_DenyAllProvider(), AuthorizationProvider)
|
||||
|
||||
def test_plain_object_without_methods_is_not_provider(self):
|
||||
class _NotAProvider:
|
||||
pass
|
||||
|
||||
assert not isinstance(_NotAProvider(), AuthorizationProvider)
|
||||
|
||||
def test_provider_without_filter_resources_is_not_provider(self):
|
||||
"""filter_resources is a required Protocol method.
|
||||
|
||||
A provider that only implements name/authorize/aauthorize but omits
|
||||
filter_resources must NOT pass isinstance — otherwise it would be
|
||||
silently accepted as an AuthorizationProvider and Layer 1 would
|
||||
get None (fail-open) when calling filter_resources.
|
||||
"""
|
||||
|
||||
class _NoFilterMethod:
|
||||
name = "no-filter"
|
||||
|
||||
def authorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
return AuthzDecision(allow=True)
|
||||
|
||||
async def aauthorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
return self.authorize(request)
|
||||
|
||||
assert not isinstance(_NoFilterMethod(), AuthorizationProvider)
|
||||
|
||||
|
||||
# --- Dataclass construction ---
|
||||
|
||||
|
||||
class TestDataclasses:
|
||||
"""Verify Principal / AuthzRequest / AuthzDecision construction."""
|
||||
|
||||
def test_principal_defaults(self):
|
||||
p = Principal()
|
||||
assert p.user_id is None
|
||||
assert p.role is None
|
||||
assert p.oauth_provider is None
|
||||
assert p.oauth_id is None
|
||||
assert p.channel_user_id is None
|
||||
assert p.is_internal is False
|
||||
assert p.attributes == {}
|
||||
|
||||
def test_principal_with_fields(self):
|
||||
p = Principal(user_id="u1", role="admin", oauth_provider="github", oauth_id="gh-123", is_internal=True)
|
||||
assert p.user_id == "u1"
|
||||
assert p.role == "admin"
|
||||
assert p.oauth_provider == "github"
|
||||
assert p.oauth_id == "gh-123"
|
||||
assert p.is_internal is True
|
||||
|
||||
def test_authz_request(self):
|
||||
p = Principal(user_id="u1", role="user")
|
||||
req = AuthzRequest(principal=p, resource="tool", action="call", target="bash")
|
||||
assert req.principal.user_id == "u1"
|
||||
assert req.resource == "tool"
|
||||
assert req.action == "call"
|
||||
assert req.target == "bash"
|
||||
assert req.context == {}
|
||||
|
||||
def test_authz_request_with_context(self):
|
||||
p = Principal(user_id="u1")
|
||||
req = AuthzRequest(principal=p, resource="tool", action="call", target="write_file", context={"thread_id": "t1"})
|
||||
assert req.context["thread_id"] == "t1"
|
||||
|
||||
def test_authz_decision_defaults(self):
|
||||
d = AuthzDecision(allow=True)
|
||||
assert d.allow is True
|
||||
assert d.reasons == []
|
||||
assert d.policy_id is None
|
||||
assert d.metadata == {}
|
||||
|
||||
def test_authz_decision_with_reasons(self):
|
||||
d = AuthzDecision(allow=False, reasons=[AuthzReason(code="denied", message="no access")], policy_id="p1")
|
||||
assert d.allow is False
|
||||
assert len(d.reasons) == 1
|
||||
assert d.reasons[0].code == "denied"
|
||||
assert d.reasons[0].message == "no access"
|
||||
assert d.policy_id == "p1"
|
||||
|
||||
|
||||
# --- filter_resources ---
|
||||
|
||||
|
||||
class TestFilterResources:
|
||||
"""Verify the Layer 1 batch filter."""
|
||||
|
||||
def test_allow_all_returns_all(self):
|
||||
provider = _AllowAllProvider()
|
||||
result = provider.filter_resources(Principal(role="user"), "tool", ["bash", "web_search", "read_file"])
|
||||
assert result == ["bash", "web_search", "read_file"]
|
||||
|
||||
def test_deny_all_returns_empty(self):
|
||||
provider = _DenyAllProvider()
|
||||
result = provider.filter_resources(Principal(role="user"), "tool", ["bash", "web_search"])
|
||||
assert result == []
|
||||
|
||||
def test_denylist_filter_removes_denied(self):
|
||||
provider = _FilterByDenylistProvider(denied=["bash", "write_file"])
|
||||
result = provider.filter_resources(Principal(role="user"), "tool", ["bash", "web_search", "write_file", "read_file"])
|
||||
assert result == ["web_search", "read_file"]
|
||||
|
||||
|
||||
# --- GuardrailAuthorizationAdapter ---
|
||||
|
||||
|
||||
def _make_guardrail_request(
|
||||
*,
|
||||
tool_name: str = "bash",
|
||||
tool_input: dict | None = None,
|
||||
user_id: str | None = "u1",
|
||||
user_role: str | None = "user",
|
||||
thread_id: str | None = "t1",
|
||||
is_subagent: bool = False,
|
||||
agent_id: str | None = None,
|
||||
timestamp: str = "",
|
||||
) -> GuardrailRequest:
|
||||
return GuardrailRequest(
|
||||
tool_name=tool_name,
|
||||
tool_input=tool_input or {},
|
||||
user_id=user_id,
|
||||
user_role=user_role,
|
||||
thread_id=thread_id,
|
||||
is_subagent=is_subagent,
|
||||
agent_id=agent_id,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
class TestGuardrailAuthorizationAdapter:
|
||||
"""Verify the adapter maps between Guardrail and Authz request/decision types."""
|
||||
|
||||
def test_adapter_name(self):
|
||||
adapter = GuardrailAuthorizationAdapter(_AllowAllProvider())
|
||||
assert adapter.name == "authorization"
|
||||
|
||||
def test_adapter_is_guardrail_provider(self):
|
||||
"""The adapter must satisfy the GuardrailProvider Protocol so existing
|
||||
GuardrailMiddleware can enforce authz decisions without a new middleware."""
|
||||
adapter = GuardrailAuthorizationAdapter(_AllowAllProvider())
|
||||
assert isinstance(adapter, GuardrailProvider)
|
||||
|
||||
def test_evaluate_allow(self):
|
||||
adapter = GuardrailAuthorizationAdapter(_AllowAllProvider())
|
||||
gr_req = _make_guardrail_request(tool_name="web_search")
|
||||
decision = adapter.evaluate(gr_req)
|
||||
assert decision.allow is True
|
||||
assert len(decision.reasons) == 1
|
||||
assert decision.reasons[0].code == "test.allowed"
|
||||
|
||||
def test_evaluate_deny(self):
|
||||
adapter = GuardrailAuthorizationAdapter(_DenyAllProvider())
|
||||
gr_req = _make_guardrail_request(tool_name="bash")
|
||||
decision = adapter.evaluate(gr_req)
|
||||
assert decision.allow is False
|
||||
assert decision.policy_id == "test.deny.v1"
|
||||
|
||||
def test_evaluate_maps_principal_identity(self):
|
||||
"""Verify user_role and user_id flow into the AuthzRequest principal."""
|
||||
captured: list[AuthzRequest] = []
|
||||
|
||||
class _CapturingProvider:
|
||||
name = "capturing"
|
||||
|
||||
def authorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
captured.append(request)
|
||||
return AuthzDecision(allow=True, reasons=[AuthzReason(code="ok")])
|
||||
|
||||
async def aauthorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
return self.authorize(request)
|
||||
|
||||
def filter_resources(self, principal: Principal, resource_type: str, candidates: list[str]) -> list[str]:
|
||||
return list(candidates)
|
||||
|
||||
adapter = GuardrailAuthorizationAdapter(_CapturingProvider())
|
||||
gr_req = _make_guardrail_request(user_id="user-42", user_role="admin", tool_name="write_file")
|
||||
adapter.evaluate(gr_req)
|
||||
|
||||
assert len(captured) == 1
|
||||
authz_req = captured[0]
|
||||
assert authz_req.principal.user_id == "user-42"
|
||||
assert authz_req.principal.role == "admin"
|
||||
assert authz_req.resource == "tool"
|
||||
assert authz_req.action == "call"
|
||||
assert authz_req.target == "write_file"
|
||||
|
||||
def test_evaluate_does_not_populate_is_internal_in_phase0(self):
|
||||
"""is_internal is not populated by the adapter in Phase 0.
|
||||
|
||||
The correct signal (auth_source == AUTH_SOURCE_INTERNAL) lives on
|
||||
request.state, not on GuardrailRequest. The adapter does not set
|
||||
is_internal, so Principal retains its dataclass default (False).
|
||||
Phase 1 will thread the signal into run context.
|
||||
"""
|
||||
captured: list[AuthzRequest] = []
|
||||
|
||||
class _CapturingProvider:
|
||||
name = "capturing"
|
||||
|
||||
def authorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
captured.append(request)
|
||||
return AuthzDecision(allow=True)
|
||||
|
||||
async def aauthorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
return self.authorize(request)
|
||||
|
||||
def filter_resources(self, principal: Principal, resource_type: str, candidates: list[str]) -> list[str]:
|
||||
return list(candidates)
|
||||
|
||||
adapter = GuardrailAuthorizationAdapter(_CapturingProvider())
|
||||
adapter.evaluate(_make_guardrail_request(user_role="user"))
|
||||
|
||||
# is_internal retains its dataclass default — adapter does not set it
|
||||
assert captured[0].principal.is_internal is False
|
||||
|
||||
def test_evaluate_maps_context_fields(self):
|
||||
"""Verify thread_id, tool_input, and is_subagent flow into AuthzRequest.context."""
|
||||
captured: list[AuthzRequest] = []
|
||||
|
||||
class _CapturingProvider:
|
||||
name = "capturing"
|
||||
|
||||
def authorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
captured.append(request)
|
||||
return AuthzDecision(allow=True)
|
||||
|
||||
async def aauthorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
return self.authorize(request)
|
||||
|
||||
def filter_resources(self, principal: Principal, resource_type: str, candidates: list[str]) -> list[str]:
|
||||
return list(candidates)
|
||||
|
||||
adapter = GuardrailAuthorizationAdapter(_CapturingProvider())
|
||||
gr_req = _make_guardrail_request(
|
||||
tool_name="write_file",
|
||||
tool_input={"path": "/tmp/test.txt"},
|
||||
thread_id="thread-99",
|
||||
is_subagent=True,
|
||||
agent_id="passport-42",
|
||||
timestamp="2026-07-13T00:00:00Z",
|
||||
)
|
||||
adapter.evaluate(gr_req)
|
||||
|
||||
ctx = captured[0].context
|
||||
assert ctx["thread_id"] == "thread-99"
|
||||
assert ctx["tool_input"] == {"path": "/tmp/test.txt"}
|
||||
assert ctx["is_subagent"] is True
|
||||
assert ctx["agent_id"] == "passport-42"
|
||||
assert ctx["timestamp"] == "2026-07-13T00:00:00Z"
|
||||
|
||||
def test_custom_resource_type_and_action(self):
|
||||
"""The adapter can be configured for non-tool resource types."""
|
||||
captured: list[AuthzRequest] = []
|
||||
|
||||
class _CapturingProvider:
|
||||
name = "capturing"
|
||||
|
||||
def authorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
captured.append(request)
|
||||
return AuthzDecision(allow=True)
|
||||
|
||||
async def aauthorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
return self.authorize(request)
|
||||
|
||||
def filter_resources(self, principal: Principal, resource_type: str, candidates: list[str]) -> list[str]:
|
||||
return list(candidates)
|
||||
|
||||
adapter = GuardrailAuthorizationAdapter(_CapturingProvider(), resource_type="model", action="use")
|
||||
adapter.evaluate(_make_guardrail_request(tool_name="claude-sonnet-4-6"))
|
||||
|
||||
assert captured[0].resource == "model"
|
||||
assert captured[0].action == "use"
|
||||
|
||||
def test_aevaluate_allow(self):
|
||||
adapter = GuardrailAuthorizationAdapter(_AllowAllProvider())
|
||||
gr_req = _make_guardrail_request(tool_name="web_search")
|
||||
decision = asyncio.run(adapter.aevaluate(gr_req))
|
||||
assert decision.allow is True
|
||||
|
||||
def test_aevaluate_deny(self):
|
||||
adapter = GuardrailAuthorizationAdapter(_DenyAllProvider())
|
||||
gr_req = _make_guardrail_request(tool_name="bash")
|
||||
decision = asyncio.run(adapter.aevaluate(gr_req))
|
||||
assert decision.allow is False
|
||||
|
||||
def test_decision_conversion_preserves_metadata(self):
|
||||
class _MetadataProvider:
|
||||
name = "metadata"
|
||||
|
||||
def authorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
return AuthzDecision(
|
||||
allow=True,
|
||||
reasons=[AuthzReason(code="ok", message="allowed by policy X")],
|
||||
policy_id="rbac.v1",
|
||||
metadata={"rule_id": "rule-42"},
|
||||
)
|
||||
|
||||
async def aauthorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
return self.authorize(request)
|
||||
|
||||
def filter_resources(self, principal: Principal, resource_type: str, candidates: list[str]) -> list[str]:
|
||||
return list(candidates)
|
||||
|
||||
adapter = GuardrailAuthorizationAdapter(_MetadataProvider())
|
||||
decision = adapter.evaluate(_make_guardrail_request())
|
||||
|
||||
assert isinstance(decision, GuardrailDecision)
|
||||
assert decision.policy_id == "rbac.v1"
|
||||
assert decision.metadata == {"rule_id": "rule-42"}
|
||||
assert decision.reasons[0].message == "allowed by policy X"
|
||||
|
||||
def test_evaluate_propagates_provider_exception(self):
|
||||
"""Provider exceptions propagate to the caller (sync).
|
||||
|
||||
The adapter intentionally does not catch provider exceptions.
|
||||
GuardrailMiddleware's wrap_tool_call applies fail_closed semantics
|
||||
(deny on error when fail_closed=True). Catching here would duplicate
|
||||
that logic and risk divergent behavior between layers.
|
||||
"""
|
||||
|
||||
class _ExplodingProvider:
|
||||
name = "exploding"
|
||||
|
||||
def authorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
raise RuntimeError("provider crashed")
|
||||
|
||||
async def aauthorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
raise RuntimeError("provider crashed")
|
||||
|
||||
def filter_resources(self, principal: Principal, resource_type: str, candidates: list[str]) -> list[str]:
|
||||
return list(candidates)
|
||||
|
||||
adapter = GuardrailAuthorizationAdapter(_ExplodingProvider())
|
||||
with pytest.raises(RuntimeError, match="provider crashed"):
|
||||
adapter.evaluate(_make_guardrail_request())
|
||||
|
||||
def test_aevaluate_propagates_provider_exception(self):
|
||||
"""Provider exceptions propagate to the caller (async).
|
||||
|
||||
Same rationale as the sync variant: GuardrailMiddleware's
|
||||
awrap_tool_call handles fail_closed.
|
||||
"""
|
||||
|
||||
class _ExplodingProvider:
|
||||
name = "exploding"
|
||||
|
||||
def authorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
raise RuntimeError("provider crashed")
|
||||
|
||||
async def aauthorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
raise RuntimeError("provider crashed")
|
||||
|
||||
def filter_resources(self, principal: Principal, resource_type: str, candidates: list[str]) -> list[str]:
|
||||
return list(candidates)
|
||||
|
||||
adapter = GuardrailAuthorizationAdapter(_ExplodingProvider())
|
||||
with pytest.raises(RuntimeError, match="provider crashed"):
|
||||
asyncio.run(adapter.aevaluate(_make_guardrail_request()))
|
||||
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
|
||||
class TestAuthorizationConfig:
|
||||
"""Verify config defaults, singleton behavior, and AppConfig wiring."""
|
||||
|
||||
def teardown_method(self):
|
||||
reset_authorization_config()
|
||||
|
||||
def test_defaults(self):
|
||||
config = AuthorizationConfig()
|
||||
assert config.enabled is False
|
||||
assert config.fail_closed is True
|
||||
assert config.default_role == "user"
|
||||
assert config.provider is None
|
||||
|
||||
def test_get_returns_defaults_when_not_loaded(self):
|
||||
reset_authorization_config()
|
||||
config = get_authorization_config()
|
||||
assert config.enabled is False
|
||||
|
||||
def test_load_from_dict(self):
|
||||
config = load_authorization_config_from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"default_role": "guest",
|
||||
"provider": {
|
||||
"use": "my_package:MyProvider",
|
||||
"config": {"roles": {"admin": {}}},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert config.enabled is True
|
||||
assert config.default_role == "guest"
|
||||
assert config.provider is not None
|
||||
assert config.provider.use == "my_package:MyProvider"
|
||||
assert config.provider.config == {"roles": {"admin": {}}}
|
||||
|
||||
def test_singleton_persistence(self):
|
||||
load_authorization_config_from_dict({"enabled": True})
|
||||
config2 = get_authorization_config()
|
||||
assert config2.enabled is True
|
||||
|
||||
def test_reset_clears_singleton(self):
|
||||
load_authorization_config_from_dict({"enabled": True})
|
||||
reset_authorization_config()
|
||||
config = get_authorization_config()
|
||||
assert config.enabled is False
|
||||
|
||||
def test_app_config_has_authorization_field(self):
|
||||
"""Verify AuthorizationConfig is wired into AppConfig with correct defaults."""
|
||||
app_config = AppConfig(sandbox=SandboxConfig(use="test"))
|
||||
assert hasattr(app_config, "authorization")
|
||||
assert app_config.authorization.enabled is False
|
||||
assert app_config.authorization.fail_closed is True
|
||||
assert app_config.authorization.default_role == "user"
|
||||
|
||||
def test_app_config_load_propagates_to_singleton(self):
|
||||
"""Verify _apply_singleton_configs populates the authorization singleton.
|
||||
|
||||
model_validate alone does NOT call _apply_singleton_configs (that runs
|
||||
only in from_file). We call it directly to verify the wiring line
|
||||
``load_authorization_config_from_dict(config.authorization.model_dump())``
|
||||
actually populates the singleton — deleting that line should fail this test.
|
||||
"""
|
||||
reset_authorization_config()
|
||||
validated = AppConfig.model_validate(
|
||||
{
|
||||
"sandbox": {"use": "test"},
|
||||
"authorization": {
|
||||
"enabled": True,
|
||||
"default_role": "operator",
|
||||
},
|
||||
}
|
||||
)
|
||||
# Drive the singleton wiring the same way from_file does.
|
||||
AppConfig._apply_singleton_configs(validated, acp_agents={})
|
||||
|
||||
singleton = get_authorization_config()
|
||||
assert singleton.enabled is True
|
||||
assert singleton.default_role == "operator"
|
||||
@ -15,7 +15,7 @@
|
||||
# ============================================================================
|
||||
# Bump this number when the config schema changes.
|
||||
# Run `make config-upgrade` to merge new fields into your local config.yaml.
|
||||
config_version: 24
|
||||
config_version: 25
|
||||
|
||||
# ============================================================================
|
||||
# Logging
|
||||
@ -1913,6 +1913,16 @@ run_ownership:
|
||||
# config:
|
||||
# key: value
|
||||
|
||||
# ============================================================================
|
||||
# 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.
|
||||
# See RFC: https://github.com/bytedance/deer-flow/issues/4063
|
||||
authorization:
|
||||
enabled: false
|
||||
|
||||
# ============================================================================
|
||||
# Circuit Breaker Configuration
|
||||
# ============================================================================
|
||||
|
||||
@ -124,7 +124,7 @@ they resolve from the `secrets` map):
|
||||
|
||||
```yaml
|
||||
config: |
|
||||
config_version: 24
|
||||
config_version: 25
|
||||
models:
|
||||
- name: gpt-4
|
||||
use: langchain_openai:ChatOpenAI
|
||||
|
||||
@ -221,7 +221,7 @@ ingress:
|
||||
# -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never
|
||||
# inline literal secret values here. The default enables provisioner sandbox.
|
||||
config: |
|
||||
config_version: 24
|
||||
config_version: 25
|
||||
log_level: info
|
||||
|
||||
models: []
|
||||
|
||||
528
docs/plans/2026-07-10-pluggable-authorization-rfc.md
Normal file
528
docs/plans/2026-07-10-pluggable-authorization-rfc.md
Normal file
@ -0,0 +1,528 @@
|
||||
<!-- Authored by @zhfeng, discussed in https://github.com/bytedance/deer-flow/issues/4063.
|
||||
Added to the PR per @WillemJiang's request for design tracking. -->
|
||||
|
||||
# RFC: Pluggable Fine-Grained Authorization
|
||||
|
||||
**Status:** Draft for feedback (responds to [#3462](https://github.com/bytedance/deer-flow/issues/3462)).
|
||||
**Affects:** `backend/packages/harness/deerflow/authz/` (new), `backend/app/gateway/authz.py`, `backend/packages/harness/deerflow/guardrails/`, `backend/packages/harness/deerflow/agents/lead_agent/agent.py`, `backend/packages/harness/deerflow/client.py`, `backend/packages/harness/deerflow/subagents/executor.py`, `backend/app/gateway/services.py`, `config.example.yaml`.
|
||||
|
||||
> Maintainer guidance from the issue ([@WillemJiang](https://github.com/bytedance/deer-flow/issues/3462)):
|
||||
> *"在开始实现之前,我们需要梳理相关的资源与权限的管理映射关系。具体工具调用和资源访问这块可以结合现有的 GuardrailProvider 来进行整合。另外建议在实现之前,做一个 RFC 的设计,方便大家的提反馈建议。"*
|
||||
>
|
||||
> This RFC does exactly that: (1) maps the resource × permission space, (2) integrates with `GuardrailProvider`, (3) is an RFC for feedback before any implementation.
|
||||
|
||||
## TL;DR
|
||||
|
||||
DeerFlow's authorization today is **authentication + ownership**: every authenticated user gets every API permission (`authz.py:144` hands all users `_ALL_PERMISSIONS`), and the only real authorization beyond login is thread ownership (`owner_check`) and a handful of hard-coded `require_admin_user()` routes. There is **no resource-level authorization** — no role can be told "you may not call `write_file`", "you may not use model X", or "you may not run sandbox code".
|
||||
|
||||
This RFC proposes a **single pluggable `AuthorizationProvider` Protocol** that is the policy brain for all fine-grained authz, enforced at **two layers** from one policy:
|
||||
|
||||
1. **Capability filtering (assembly-time)** — removes tools a role can never use *before* they are bound to the agent, so the model never sees them and `tool_search` can never promote them back (fail-closed).
|
||||
2. **Execution authorization (run-time)** — a per-call allow/deny that reuses the existing `GuardrailMiddleware` as its enforcement point, catching dynamic resources and argument-based restrictions.
|
||||
|
||||
We ship one built-in provider — `RbacAuthorizationProvider` — that reads `config.yaml`, so it works out-of-the-box (no code), and enterprises with LDAP/OPA/OAuth-scopes replace it by setting one class path. This is a deliberate **hybrid of "Plan A" (pluggable hook) and "Plan B" (built-in RBAC)** from the issue thread: pluggable surface, batteries-included default.
|
||||
|
||||
The two hard problems raised in the thread — **dynamic resources** and the **filter-vs-deny security boundary** — are both resolved by the two-layer design (see §7).
|
||||
|
||||
---
|
||||
|
||||
## 1. Background: what exists today
|
||||
|
||||
### 1.1 Authentication (who you are) — solid
|
||||
|
||||
`AuthMiddleware` (`backend/app/gateway/auth_middleware.py:65`) is a strict session gate. It stamps `request.state.user` from one of three sources:
|
||||
|
||||
| Source | `system_role` | When |
|
||||
|---|---|---|
|
||||
| Session (JWT cookie → `User`) | `"admin"` or `"user"` | Browser / API callers |
|
||||
| Internal (`X-DeerFlow-Internal-Token`) | `"internal"` | IM channel workers, scheduler |
|
||||
| Auth-disabled | `"admin"` | `auth_disabled` mode |
|
||||
|
||||
The `User` model (`backend/app/gateway/auth/models.py:15`) carries `system_role: Literal["admin", "user"]`; the DB column is `String(16)` *deliberately*, with a comment: *"kept as plain string to avoid ALTER TABLE pain when new roles are introduced"* (`persistence/user/model.py:33`). **The schema is already forward-compatible with new roles.**
|
||||
|
||||
### 1.2 Authorization (what you can do) — a placeholder
|
||||
|
||||
- **Route-level:** `@require_permission("resource", "action")` (`authz.py:197`) checks `AuthContext.permissions`. But `_authenticate()` (`authz.py:131`) sets `permissions=_ALL_PERMISSIONS` for *every* authenticated user. The comment at `authz.py:143` admits it: *"In future, permissions could be stored in user record."* It is a placeholder.
|
||||
- **Ownership:** `owner_check=True` (`authz.py:278`) scopes threads/runs to their owner via `ThreadMetaStore.check_access`. This is real and works.
|
||||
- **Admin routes:** `require_admin_user()` (`deps.py:482`) hard-codes `system_role == "admin"` for skill / MCP / channel-management endpoints.
|
||||
- **Tool-level:** **none.** No role can be restricted from any tool, model, skill, or sandbox.
|
||||
|
||||
### 1.3 The GuardrailProvider (the integration point)
|
||||
|
||||
The maintainer pointed here for good reason. `GuardrailProvider` (`deerflow/guardrails/provider.py:46`) is already a pluggable, class-path-loaded, per-call authorization hook:
|
||||
|
||||
```python
|
||||
@runtime_checkable
|
||||
class GuardrailProvider(Protocol):
|
||||
name: str
|
||||
def evaluate(self, request: GuardrailRequest) -> GuardrailDecision: ...
|
||||
async def aevaluate(self, request: GuardrailRequest) -> GuardrailDecision: ...
|
||||
```
|
||||
|
||||
`GuardrailRequest` **already carries identity** — `user_id`, `user_role`, `oauth_provider`, `oauth_id`, `thread_id`, `run_id`, `is_subagent`, `tool_call_id` — populated from the run context by `GuardrailMiddleware._build_request` (`guardrails/middleware.py:42`). It is wired by `GuardrailMiddleware` (`guardrails/middleware.py:22`), which blocks denied calls with an error `ToolMessage`, fails closed by default, and audits to `RunJournal`. It is attached for both lead agent and subagents in `_build_runtime_middlewares()` (`agents/middlewares/tool_error_handling_middleware.py:202`).
|
||||
|
||||
**What guardrails cannot do alone:** they fire *per tool call*, at *execution time*. They cannot remove a tool from the schema the model sees, so a denied-but-visible tool is still a prompt-injection surface, and they cannot prevent `tool_search` from promoting a tool back into view. Guardrails are the **execution layer**; they are not a **capability/visibility layer**. This RFC makes them one half of a two-layer system.
|
||||
|
||||
### 1.4 The tool assembly pipeline (where visibility is decided)
|
||||
|
||||
All tools are merged in `get_available_tools()` (`deerflow/tools/tools.py:44`): config tools + built-ins + MCP (cached, hot-reloadable by mtime) + ACP, deduped by name. In the lead agent (`agents/lead_agent/agent.py:561`):
|
||||
|
||||
```python
|
||||
raw_tools = get_available_tools(...)
|
||||
filtered = filter_tools_by_skill_allowed_tools(raw_tools + extra_tools, skills, ...) # name allowlist from skills
|
||||
if non_interactive:
|
||||
filtered = [t for t in filtered if t.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES]
|
||||
final_tools, setup = assemble_deferred_tools(filtered, enabled=...) # deferred catalog built from `filtered`
|
||||
```
|
||||
|
||||
The skill filter (`skills/tool_policy.py:42`) is the **exact pattern** a role filter should mirror: a name-set intersection. Crucially, `assemble_deferred_tools` builds the `tool_search` catalog from the *post-filter* list (`tool_search.py:182`), so anything removed before this point can never be promoted back. **This is the fail-closed insertion point.**
|
||||
|
||||
Three agent build paths assemble tools, and they must all apply the same filter or the policy is bypassable:
|
||||
|
||||
| Path | File | Skill filter applied? |
|
||||
|---|---|---|
|
||||
| Lead agent | `agents/lead_agent/agent.py:562` | ✅ |
|
||||
| Subagent | `subagents/executor.py:578` (`_apply_skill_allowed_tools`) | ✅ |
|
||||
| `DeerFlowClient` | `client.py:259` | ❌ *(passes `tools` straight to `assemble_deferred_tools`)* |
|
||||
|
||||
The `DeerFlowClient` gap is a pre-existing inconsistency; any new authz filter must be applied on all three.
|
||||
|
||||
### 1.5 Identity flow into the run — reliable for the web, gap for channels
|
||||
|
||||
`inject_authenticated_user_context()` (`app/gateway/services.py:278`) stamps `user_id` / `user_role` / `oauth_provider` / `oauth_id` into `config["context"]`, which `GuardrailMiddleware` reads. Reliability:
|
||||
|
||||
| Caller path | `user_role` |
|
||||
|---|---|
|
||||
| Web / browser session | always populated (`"admin"`/`"user"`) |
|
||||
| Auth-disabled | always `"admin"` |
|
||||
| IM channel, **bound** connection | connection owner's `system_role` |
|
||||
| IM channel, **unbound** / lookup miss | **`None`** (explicitly popped, `services.py:301`) |
|
||||
| Scheduled task | owner's role if resolvable, else `None` |
|
||||
|
||||
So `user_role` reaches the tool layer reliably **except for unbound IM channels**, where it is `None`. Any role-based design must define behavior for the `None` case (see §8).
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals & non-goals
|
||||
|
||||
### Goals
|
||||
|
||||
- **G1 — Pluggable.** A single `AuthorizationProvider` Protocol, class-path-loaded exactly like `GuardrailProvider` / models / sandbox (`resolve_variable`). Enterprises plug LDAP, OPA, OAuth-scopes, or home-grown RBAC; no fork required.
|
||||
- **G2 — Out-of-the-box.** A built-in `RbacAuthorizationProvider` reads `config.yaml`; operators configure roles without writing Python.
|
||||
- **G3 — Resource-level.** Covers tools, models, skills, sandbox, MCP, and routes — not just API permissions.
|
||||
- **G4 — Defense-in-depth.** Visibility filter **and** execution deny, from one policy. Neither alone is sufficient against prompt injection or dynamic promotion.
|
||||
- **G5 — Fail-closed.** Provider error, missing role, or unresolvable identity defaults to deny (configurable to fail-open for non-security contexts).
|
||||
- **G6 — Non-breaking.** Default config (`authorization.enabled: false`) preserves today's behavior (all authenticated users get everything). Adoption is opt-in per deployment.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- **Not** a full user-management UI (create/disable users, bulk ops). The issue asks for it; this RFC covers *authorization*, not identity lifecycle. User provisioning remains in the auth module.
|
||||
- **Not** per-message/per-sender IM roles. Channel senders inherit the connection owner's role today; per-sender identity is a separate, larger effort (see §11 open questions).
|
||||
- **Not** replacing ownership checks. `owner_check` stays; the provider *adds* role policy on top.
|
||||
- **Not** re-implementing what `require_admin_user` already does for management routes. Those migrate to the provider in a later phase, but behavior is preserved.
|
||||
|
||||
---
|
||||
|
||||
## 3. Resource × Permission map
|
||||
|
||||
The maintainer's first ask: *“梳理相关的资源与权限的管理映射关系.”* Here is the full inventory.
|
||||
|
||||
| Resource | Identifier (`target`) | Actions | Current enforcement | Proposed enforcement |
|
||||
|---|---|---|---|---|
|
||||
| **Tool** (built-in / MCP / ACP) | tool name (`write_file`, `mcp__github__*`) | `call` | none | assembly filter + guardrail (§5) |
|
||||
| **Model** | model name (`claude-sonnet-4-6`) | `list`, `use` | none | models router + `_resolve_model_name` |
|
||||
| **Skill** | skill name | `activate`, `read`, `manage` | `manage` admin-gated; `activate` ungated | `SkillActivationMiddleware` + `describe_skill` + (keep admin gate) |
|
||||
| **Sandbox** | (singleton) | `execute` | none (global on/off) | `SandboxMiddleware` |
|
||||
| **MCP server** | server name | `use` (its tools), `manage` | `manage` admin-gated | tool filter covers `use`; keep admin gate for `manage` |
|
||||
| **Thread** | thread_id | `read`, `write`, `delete` | `owner_check` ✅ | keep ownership; provider may add role policy |
|
||||
| **Run** | run_id | `create`, `read`, `cancel` | ownership via thread | keep |
|
||||
| **Route** | `resource:action` (`threads:read`) | (decorator) | `_ALL_PERMISSIONS` placeholder | provider-derived permissions (§6.3) |
|
||||
| **Scheduled task** | task_id | `create`, `manage`, `execute` | router-level auth | provider |
|
||||
| **Agent self-mutation** | `update_agent` tool | `call` | withheld from webhook channels ✅ | assembly filter (per-role deny) + keep webhook withhold |
|
||||
| **Memory** | (owner-scoped) | `read`, `write` | owner-scoped ✅ | keep |
|
||||
|
||||
**The headline gap is the first four rows** — tools, models, skills, sandbox have **no** role gate today. Routes are nominally gated but effectively wide-open via `_ALL_PERMISSIONS`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Proposed architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ AuthorizationProvider │ ← pluggable policy brain
|
||||
│ (Protocol, class-path) │ (built-in: RbacAuthorizationProvider)
|
||||
└──────────────┬───────────────┘
|
||||
│ one policy
|
||||
┌────────────────────┴───────────────────────┐
|
||||
▼ ▼
|
||||
┌───────────────────────────────┐ ┌──────────────────────────────────┐
|
||||
│ Layer 1: Capability filter │ │ Layer 2: Execution authorization │
|
||||
│ (assembly-time, per build) │ │ (run-time, per call) │
|
||||
│ │ │ │
|
||||
│ filter_resources(principal, │ │ authorize(AuthzRequest) │
|
||||
│ "tool", candidates) -> list │ │ reuses GuardrailMiddleware │
|
||||
│ │ │ via a thin adapter │
|
||||
│ removes tools before │ │ │
|
||||
│ create_agent → invisible + │ │ catches dynamic resources + │
|
||||
│ unpromotable (fail-closed) │ │ argument-based deny │
|
||||
└───────────────────────────────┘ └──────────────────────────────────┘
|
||||
```
|
||||
|
||||
**One policy, two enforcement layers.** Layer 1 answers *"what can this role's agent ever see?"* (static, batch, at build). Layer 2 answers *"may this specific call proceed?"* (dynamic, per-call, at execution). Both consult the same provider, so an enterprise's LDAP-backed provider is the single source of truth.
|
||||
|
||||
### Why two layers (not one)
|
||||
|
||||
- **Layer 1 only** (visibility filter) — vulnerable to any future code path that binds a tool after the filter runs (e.g. a new dynamic tool source). Also leaks nothing, but can't do per-argument policy.
|
||||
- **Layer 2 only** (guardrail) — the model still *sees* every tool schema, so a denied tool is a prompt-injection target and consumes context. hata33's concern in the thread is exactly this.
|
||||
- **Both** — Layer 1 removes tools the role can never use (clean schema, no injection surface, unpromotable); Layer 2 is the safety net for anything that slips through and the place for argument-based rules. **This is the design.**
|
||||
|
||||
---
|
||||
|
||||
## 5. The `AuthorizationProvider` Protocol
|
||||
|
||||
Lives in a new package `deerflow/authz/` (harness), mirroring `deerflow/guardrails/`.
|
||||
|
||||
```python
|
||||
# deerflow/authz/provider.py
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
@dataclass
|
||||
class Principal:
|
||||
"""The actor. Built once per run from request.state.user + context."""
|
||||
user_id: str | None = None
|
||||
role: str | None = None # system_role today; provider may map richer
|
||||
oauth_provider: str | None = None
|
||||
oauth_id: str | None = None
|
||||
channel_user_id: str | None = None # IM sender (distinct from connection owner)
|
||||
is_internal: bool = False # internal/system caller
|
||||
attributes: dict[str, Any] = field(default_factory=dict) # extensible: dept, team, quota…
|
||||
|
||||
@dataclass
|
||||
class AuthzRequest:
|
||||
principal: Principal
|
||||
resource: str # "tool" | "model" | "skill" | "sandbox" | "mcp_server" | "thread" | "route" | …
|
||||
action: str # "call" | "list" | "use" | "activate" | "execute" | "read" | "write" | "delete" | "manage"
|
||||
target: str # resource id: tool name, model name, skill name, "route:threads:read", …
|
||||
context: dict[str, Any] = field(default_factory=dict) # thread_id, run_id, tool args, …
|
||||
|
||||
@dataclass
|
||||
class AuthzReason:
|
||||
code: str
|
||||
message: str = ""
|
||||
|
||||
@dataclass
|
||||
class AuthzDecision:
|
||||
allow: bool
|
||||
reasons: list[AuthzReason] = field(default_factory=list)
|
||||
policy_id: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@runtime_checkable
|
||||
class AuthorizationProvider(Protocol):
|
||||
"""Pluggable fine-grained authorization. No base class required.
|
||||
|
||||
Loaded by class path via resolve_variable() — the same mechanism as
|
||||
GuardrailProvider, models, tools, and sandbox.
|
||||
"""
|
||||
name: str
|
||||
|
||||
def authorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
"""Per-call decision. Feeds Layer 2 (execution) and route checks."""
|
||||
...
|
||||
|
||||
async def aauthorize(self, request: AuthzRequest) -> AuthzDecision:
|
||||
...
|
||||
|
||||
# Layer 1: batch visibility filter. Default = "delegate to authorize per item"
|
||||
# (correct but slow). Providers with a static role→resource map override
|
||||
# this for O(1) assembly-time filtering and fail-closed visibility.
|
||||
def filter_resources(
|
||||
self, principal: Principal, resource_type: str, candidates: list[str],
|
||||
) -> list[str]: ...
|
||||
```
|
||||
|
||||
**Design notes:**
|
||||
|
||||
- `Principal` is built once per run (from `request.state.user` + context) and threaded to both layers, so providers don't re-resolve identity per call.
|
||||
- `resource` / `action` / `target` are free-form strings, not an enum, so new resource types (and provider-specific resources) need no schema change. The built-in RBAC provider interprets them; custom providers define their own.
|
||||
- `filter_resources` has a default (per-item `authorize`) so a provider that only implements `authorize` still works for Layer 1 — but the built-in RBAC provider overrides it for fail-closed static filtering.
|
||||
- Mirrors `GuardrailProvider` exactly in shape (sync + async, `@runtime_checkable`, no base class, `name` attribute) so the mental model is uniform.
|
||||
|
||||
---
|
||||
|
||||
## 6. Integration with GuardrailProvider (the maintainer's ask)
|
||||
|
||||
### 6.1 The execution layer reuses `GuardrailMiddleware` verbatim
|
||||
|
||||
We do **not** write a new middleware. `GuardrailMiddleware` already does everything Layer 2 needs: per-call hook, fail-closed, audit, error `ToolMessage`, sync+async. We add one thin adapter that presents an `AuthorizationProvider` as a `GuardrailProvider`:
|
||||
|
||||
```python
|
||||
# deerflow/authz/adapter.py
|
||||
class GuardrailAuthorizationAdapter:
|
||||
"""Adapt an AuthorizationProvider to the GuardrailProvider Protocol.
|
||||
|
||||
Lets the existing GuardrailMiddleware enforce AuthorizationProvider
|
||||
decisions at tool-call time — no new middleware class required.
|
||||
"""
|
||||
name = "authorization"
|
||||
|
||||
def __init__(self, provider: AuthorizationProvider, *, resource_type: str = "tool",
|
||||
action: str = "call"):
|
||||
self._provider = provider
|
||||
self._resource_type = resource_type
|
||||
self._action = action
|
||||
|
||||
def _to_authz(self, gr: GuardrailRequest) -> AuthzRequest:
|
||||
return AuthzRequest(
|
||||
principal=Principal(
|
||||
user_id=gr.user_id, role=gr.user_role,
|
||||
oauth_provider=gr.oauth_provider, oauth_id=gr.oauth_id,
|
||||
is_internal=(gr.user_role == "internal"),
|
||||
),
|
||||
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},
|
||||
)
|
||||
|
||||
def evaluate(self, request): # -> GuardrailDecision
|
||||
d = self._provider.authorize(self._to_authz(request))
|
||||
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)
|
||||
|
||||
async def aevaluate(self, request):
|
||||
d = await self._provider.aauthorize(self._to_authz(request))
|
||||
return GuardrailDecision(allow=d.allow, reasons=[...], policy_id=d.policy_id, metadata=d.metadata)
|
||||
```
|
||||
|
||||
### 6.2 Auto-wiring in `_build_runtime_middlewares`
|
||||
|
||||
In `agents/middlewares/tool_error_handling_middleware.py:202` (the existing guardrail block), add a parallel block: when `authorization.enabled` and no explicit `guardrails.provider` is set, auto-attach `GuardrailMiddleware(GuardrailAuthorizationAdapter(authz_provider))`. Users who want an **external** guardrail (OAP) *and* role filtering configure both sections; users who want only RBAC configure only `authorization`. The built-in `RbacAuthorizationProvider` also natively implements the `GuardrailProvider` methods, so it can be set directly as `guardrails.provider` too — the adapter is the general escape hatch.
|
||||
|
||||
### 6.3 Route-level: replacing `_ALL_PERMISSIONS`
|
||||
|
||||
`AuthContext.permissions` is today a flat constant list. We make it **provider-derived**: `_authenticate()` (`authz.py:131`) asks the provider `authorize(principal, "route", action, target=f"{resource}:{action}")` for each registered permission (cached per-request). `@require_permission` is unchanged. Behavior with `authorization.enabled: false` is identical to today (all permissions granted). This is a phased migration (§9) — we don't touch every route on day one.
|
||||
|
||||
---
|
||||
|
||||
## 7. The two hard problems (resolved)
|
||||
|
||||
### 7.1 Dynamic resources
|
||||
|
||||
| Dynamic path | Why it's a problem | How the two-layer design handles it |
|
||||
|---|---|---|
|
||||
| **MCP hot-reload** | New MCP tools appear after agent build (cache mtime check, `mcp/cache.py:31`) | Tools are read at **build time** per run (`get_available_tools`). A newly-added MCP tool appears on the *next* run, where Layer 1 filters it. Mid-run appearance doesn't happen within a built graph. |
|
||||
| **`tool_search` deferred promotion** | A hidden tool can be promoted back into the schema mid-run | Layer 1 runs **before** `assemble_deferred_tools` (`agent.py:565`). Filtered-out tools never enter the `DeferredToolCatalog` (`tool_search.py:182`), so they can **never** be promoted. Fail-closed by construction. |
|
||||
| **`McpRoutingMiddleware` auto-promotion** | Keyword-based auto-promotion could surface a tool | Same: the catalog is post-filter, so auto-promotion can only pick from already-allowed tools. |
|
||||
| **Skill runtime discovery** (`describe_skill`, `/skill` activation) | Skills can be discovered/activated mid-run | Skills are a *resource type* in the provider. `SkillActivationMiddleware` calls `authorize(principal, "skill", "activate", target=skill_name)` before injecting the skill. Denied skills never load. |
|
||||
| **Argument-based rules** (e.g. `write_file` only to `/tmp`) | Static filter can't see args | Layer 2 (`authorize` with `context.tool_input`) handles this. The tool stays visible (it's allowed in principle) but specific calls are denied. |
|
||||
|
||||
**Principle:** Layer 1 = "what you can *ever* see" (static, fails closed). Layer 2 = "may *this* call proceed" (dynamic, argument-aware). Any resource that is dynamic-only (appears after build) is caught by Layer 2; any resource known at build is removed by Layer 1 and made unpromotable.
|
||||
|
||||
### 7.2 The filter-vs-deny security boundary
|
||||
|
||||
hata33's concern: *"只做'过滤可见性'不做'拦截执行', prompt injection 仍能让模型调被隐藏的工具."*
|
||||
|
||||
This is only true if filtering happens at the **visibility** layer (`DeferredToolFilterMiddleware`, which hides schemas but doesn't remove tools from the bound set). This RFC filters at the **assembly** layer — the tool is removed from `final_tools` passed to `create_agent`, so it is *not bound to the model at all*. The model cannot call a tool it was never given.
|
||||
|
||||
The residual risk — a tool that *is* allowed at assembly (visible) but should be denied for a specific call — is exactly what Layer 2 (guardrail) catches. **Filter + deny, both enforced, from one policy.** Neither is bypassable alone:
|
||||
|
||||
- A tool removed at assembly: not bound → uncallable → no injection surface.
|
||||
- A tool visible but role-denied: guardrail blocks the call → uncallable.
|
||||
- Provider error: `fail_closed=true` → deny.
|
||||
|
||||
---
|
||||
|
||||
## 8. Identity prerequisites (close the `user_role` gap)
|
||||
|
||||
Role-based authz is only as good as the role reaching the provider. Today `user_role` is `None` for unbound IM channels (`services.py:301`). We close this in `inject_authenticated_user_context` and a new `Principal` builder:
|
||||
|
||||
1. **`default_role` config** (`authorization.default_role`, default `"user"`): when `user_role` is `None`, the `Principal.role` falls back to this. Unbound channels get a defined, restrictable role instead of an implicit "admin-by-accident."
|
||||
2. **`Principal` is built once per run** from `request.state.user` + context, in `services.py` alongside `inject_authenticated_user_context`, and stored on the run context as `principal` (alongside the existing `user_id`/`user_role`). Both layers read it.
|
||||
3. **Role taxonomy lives in config, not the schema.** `User.system_role` stays `Literal["admin","user"]` for the *built-in* identity provider, but the DB column is already `String(16)`. The RBAC config defines `guest`, `operator`, etc.; a custom identity provider can mint richer roles. No schema migration required — by design (`persistence/user/model.py:33`).
|
||||
4. **`internal` role** (channel workers/scheduler) is a real role in the RBAC config, not a special case. The provider decides what `internal` may do (typically: call tools on behalf of the owner, but no `update_agent`, no admin routes).
|
||||
|
||||
---
|
||||
|
||||
## 9. Phased rollout
|
||||
|
||||
Each phase is independently shippable and behind `authorization.enabled` (default `false` = today's behavior).
|
||||
|
||||
**Phase 0 — Foundations (no behavior change).**
|
||||
- New `deerflow/authz/` package: `provider.py` (Protocol + dataclasses), `principal.py` (builder), `adapter.py`.
|
||||
- `Principal` built in `services.py`, stored on run context.
|
||||
- `default_role` config; close the `user_role=None` gap.
|
||||
- `RbacAuthorizationProvider` skeleton + `AuthorizationConfig` (AppConfig section, singleton, live-reload — mirrors `guardrails_config.py`; **not** in `STARTUP_ONLY_FIELDS`).
|
||||
|
||||
**Phase 1 — Tool authorization (highest value, lowest risk).**
|
||||
- Layer 1: `filter_tools_by_authorization(tools, principal)` applied at `agent.py:562`, `executor.py:578`, **and `client.py:259`** (fixing the existing skill-filter gap there too).
|
||||
- Layer 2: auto-wire `GuardrailAuthorizationAdapter` in `_build_runtime_middlewares`.
|
||||
- Built-in RBAC provider covers `tool:call` with allow/deny lists + `*` wildcard.
|
||||
- Tests: per-role tool visibility, deferred-promotion fail-closed, prompt-injection-can't-call-removed-tool, subagent inheritance.
|
||||
|
||||
**Phase 2 — Route-level migration.**
|
||||
- Replace `_ALL_PERMISSIONS` with provider-derived permissions in `_authenticate()` (per-request cached).
|
||||
- `@require_permission` unchanged. **Keep `require_admin_user()` for management endpoints** (skills/MCP/channel config) — the project already settled on admin-gating there (GHSA-4693 → #3855/#3425; see §12 Q6). Only ordinary routes migrate.
|
||||
|
||||
**Phase 3 — Models, skills, sandbox.**
|
||||
- `models.py:40` `list_models` filters by `authorize("model","list")`; `_resolve_model_name` checks `authorize("model","use")` (deny → fall back to an allowed default, not error, to avoid breaking runs).
|
||||
- `SkillActivationMiddleware` + `describe_skill` gate on `authorize("skill","activate")`.
|
||||
- `SandboxMiddleware` gates on `authorize("sandbox","execute")` (deny → tool returns a "sandbox not permitted for your role" error message, not a crash).
|
||||
|
||||
**Phase 4 (optional) — Frontend.** Surface the user's effective permissions so the UI can hide disabled models/tools/menus. Out of scope for the backend RFC; noted for completeness.
|
||||
|
||||
---
|
||||
|
||||
## 10. Config schema
|
||||
|
||||
Mirrors the `guardrails` section exactly (`config.example.yaml:1786`):
|
||||
|
||||
```yaml
|
||||
# ============================================================================
|
||||
# Authorization Configuration
|
||||
# ============================================================================
|
||||
# Optional fine-grained, role-based authorization. When enabled, a pluggable
|
||||
# AuthorizationProvider decides what each role may call/use/see. Two layers
|
||||
# are enforced from one policy: assembly-time capability filtering (tools the
|
||||
# agent can never see) and run-time execution deny (reuses GuardrailMiddleware).
|
||||
# See docs/plans/2026-07-10-pluggable-authorization-rfc.md.
|
||||
|
||||
authorization:
|
||||
enabled: false
|
||||
fail_closed: true # block on provider error / unresolved identity
|
||||
default_role: user # applied when user_role is None (unbound IM channels)
|
||||
provider:
|
||||
use: deerflow.authz.rbac:RbacAuthorizationProvider
|
||||
config:
|
||||
# role -> resource policy. "*" = all. Omitted resource type = unaffected.
|
||||
roles:
|
||||
admin:
|
||||
tools: {allow: "*"}
|
||||
models: {allow: "*"}
|
||||
sandbox: {allow: true}
|
||||
skills: {allow: "*"}
|
||||
user:
|
||||
tools: {allow: "*", deny: ["update_agent"]}
|
||||
models: {allow: ["claude-sonnet-4-6", "gpt-4o"]}
|
||||
sandbox: {allow: true}
|
||||
skills: {allow: "*"}
|
||||
guest:
|
||||
tools: {allow: ["web_search", "read_file"]}
|
||||
models: {allow: ["gpt-4o-mini"]}
|
||||
sandbox: {allow: false}
|
||||
skills: {allow: []}
|
||||
internal: # IM channel workers / scheduler
|
||||
tools: {allow: "*", deny: ["update_agent"]}
|
||||
models: {allow: ["claude-sonnet-4-6"]}
|
||||
sandbox: {allow: true}
|
||||
# Optional: derive role from Principal.attributes instead of system_role.
|
||||
# Default: role = principal.role (i.e. User.system_role).
|
||||
# role_mapping:
|
||||
# source: attribute # or "system_role"
|
||||
# attribute: department
|
||||
# map: {eng: admin, cs: user}
|
||||
|
||||
# When authorization.enabled and guardrails.provider is unset, the authorization
|
||||
# provider is auto-wired as the tool-call guardrail. To use an external guardrail
|
||||
# (e.g. OAP) AS WELL, set guardrails.provider explicitly; both then enforce.
|
||||
```
|
||||
|
||||
**Built-in provider semantics:**
|
||||
- `allow: "*"` = all tools/models; `allow: [list]` = allowlist; omitted = inherit parent / allow.
|
||||
- `deny` always wins over `allow` (defense-in-depth: a deny can never be overridden).
|
||||
- Unknown role → `default_role` → if still unknown → `fail_closed` decides.
|
||||
- `sandbox: {allow: false}` makes `SandboxMiddleware` deny execution for that role.
|
||||
- Live-reloadable: `AuthorizationConfig` is a singleton reloaded by `get_app_config()`'s signature check, and the provider is re-instantiated per agent build (same as guardrails) — **not** in `STARTUP_ONLY_FIELDS`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Alternatives considered
|
||||
|
||||
| Approach | From | Verdict |
|
||||
|---|---|---|
|
||||
| **Plan A** — minimal filter hook only (`ResourcePermissionProvider.filter_tools`) | hata33 | Adopted as Layer 1, but **insufficient alone** (no execution deny → injection risk; no non-tool resources). |
|
||||
| **Plan B** — built-in config RBAC only | hata33 | Adopted as the *built-in provider*, but **insufficient alone** (enterprises still need to plug LDAP/OPA; no execution layer). |
|
||||
| **Extend `GuardrailProvider` with `filter_tools`** | — | Rejected: bloats the OAP-aligned guardrail Protocol and couples visibility (per-build) with execution (per-call) semantics. Keep guardrails minimal; add a sibling Protocol. |
|
||||
| **A new `AuthorizationMiddleware`** (parallel to guardrail) | — | Rejected: duplicates `GuardrailMiddleware`'s fail-closed/audit/error-message logic. Reuse guardrail via the adapter instead. |
|
||||
| **Store permissions in the `User` row** | `authz.py:143` comment | Rejected for the pluggable case: a DB column per permission doesn't scale to enterprise LDAP/OPA. The provider *can* be DB-backed if a deployment wants that — it's an implementation choice, not the architecture. |
|
||||
|
||||
**This RFC = Plan A's pluggability + Plan B's batteries-included default + the two-layer enforcement that neither alone provides.**
|
||||
|
||||
> **Prior-art note.** The choice to add a sibling `AuthorizationProvider` rather than extend `GuardrailProvider` is not second-guessing the guardrail design - it is what that design explicitly deferred. PR [#3665](https://github.com/bytedance/deer-flow/pull/3665) (which added `user_role`/`user_id` to `GuardrailRequest`) states its scope as: *"保持 Guardrail 的职责边界不变:不新增 policy engine、RBAC 系统、governance 子系统"* ("keeps the guardrail boundary: adds no policy engine, RBAC system, or governance subsystem"). The guardrail is the *execution enforcement point*; the RBAC brain that #3665 deliberately left out is what this RFC adds - and it reuses the guardrail as that enforcement point (§6).
|
||||
|
||||
---
|
||||
|
||||
## 12. Open questions for feedback
|
||||
|
||||
1. **Role for unbound IM channels.** Default `default_role: user`, or a dedicated `guest`-like role? Unbound channels today have no owner in the user DB; should they be allowed to run at all under `authorization.enabled`, or require a bound connection?
|
||||
2. **`internal` role scope.** Should `internal` (channel workers) be a fully-configurable role, or keep special-cased bypass semantics for backward compat with existing channel deployments?
|
||||
3. **Model-deny behavior.** When a role requests a denied model, fall back to an allowed default (graceful) or hard-deny the run (strict)? Proposal: graceful fallback + audit, but strict is defensible.
|
||||
4. **Argument-based tool rules.** In scope for the built-in RBAC provider (e.g. `write_file` path restrictions), or left to custom providers / OAP? Proposal: out of scope for v1 built-in; the `authorize` hook supports it for custom providers.
|
||||
5. **Per-sender IM roles.** Defer entirely (separate RFC), or lay groundwork now via `Principal.channel_user_id`? Proposal: lay the `Principal` groundwork (already in the dataclass), defer the policy.
|
||||
6. **Route migration cadence.** Migrate the `_ALL_PERMISSIONS` placeholder to provider-derived permissions, but **keep `require_admin_user()` hard-coded for management endpoints** (skills/MCP/channel config). Precedent: GHSA-4693 (#2996) proposed `@require_permission` for the MCP/memory/skills routers and was *closed*; the merged fix (#3855, #3425) chose `require_admin_user` instead. So for management surfaces, the project has already settled on admin-gating - this RFC respects that and only migrates ordinary routes. Proposal: Phase 2 migrates `_ALL_PERMISSIONS` only; management endpoints stay admin-gated (the provider may *additionally* be consulted, but `admin` remains the floor).
|
||||
|
||||
---
|
||||
|
||||
## 13. Test strategy (TDD, per AGENTS.md)
|
||||
|
||||
Backend tests in `backend/tests/`. Minimum coverage for Phase 1:
|
||||
|
||||
- `test_authz_provider_protocol.py` — Protocol conformance, `@runtime_checkable`, default `filter_resources` delegates to `authorize`.
|
||||
- `test_rbac_authorization_provider.py` — per-role allow/deny, `*` wildcard, `deny` wins, unknown role → `default_role` → `fail_closed`, all resource types.
|
||||
- `test_authz_tool_filter.py` — Layer 1: tools removed at assembly on all three build paths (lead / subagent / `DeerFlowClient`); filtered tools absent from `DeferredToolCatalog` (fail-closed promotion).
|
||||
- `test_authz_guardrail_adapter.py` — Layer 2: adapter deny → `GuardrailMiddleware` returns error `ToolMessage`; `user_role` flows from context; `fail_closed` on provider error.
|
||||
- `test_authz_prompt_injection.py` — a tool removed at assembly cannot be invoked even when the prompt tries to call it (the security-boundary guarantee).
|
||||
- `test_authz_principal.py` — `user_role=None` → `default_role`; internal caller; subagent inherits principal.
|
||||
- Extend `test_app_config_reload.py` — `authorization` section live-reloads; not in `STARTUP_ONLY_FIELDS` drift test.
|
||||
|
||||
---
|
||||
|
||||
## 14. References
|
||||
|
||||
- Issue: [#3462](https://github.com/bytedance/deer-flow/issues/3462)
|
||||
- Existing design: `backend/docs/AUTH_DESIGN.md`, `backend/docs/GUARDRAILS.md`
|
||||
- Guardrail provider: `backend/packages/harness/deerflow/guardrails/provider.py`
|
||||
- Guardrail middleware: `backend/packages/harness/deerflow/guardrails/middleware.py`
|
||||
- Tool assembly: `backend/packages/harness/deerflow/agents/lead_agent/agent.py:561`, `deerflow/tools/tools.py:44`, `deerflow/tools/builtins/tool_search.py:190`
|
||||
- Skill filter pattern: `backend/packages/harness/deerflow/skills/tool_policy.py:42`
|
||||
- Identity injection: `backend/app/gateway/services.py:278`
|
||||
- Config resolution: `backend/packages/harness/deerflow/reflection/resolvers.py`, `deerflow/config/guardrails_config.py`, `deerflow/config/reload_boundary.py`
|
||||
- Middleware wiring: `backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py:154`
|
||||
|
||||
---
|
||||
|
||||
## 15. Related work & prior art (upstream issues & PRs)
|
||||
|
||||
A search of `bytedance/deer-flow` confirms **no RBAC implementation exists**; RBAC is a recognized but unowned roadmap item. This RFC situates itself in the prior work below.
|
||||
|
||||
### Direct lineage - what this RFC builds on
|
||||
|
||||
- **[#1669](https://github.com/bytedance/deer-flow/issues/1669)** - Q2 Roadmap. Lists *"Implement Role-Based Access Control (RBAC)"* under "Security and Permission Strengthening 🔥🔥🔥🔥" (top priority), referencing #1721 and #3506. **This is the roadmap slot #3462 and this RFC fill.**
|
||||
- **[#1213](https://github.com/bytedance/deer-flow/issues/1213)** (closed) - the original RFC proposing OAP `before_tool_call` authorization for tools/skills. The `GuardrailProvider`/`GuardrailMiddleware` system is the implemented result. **Our RFC is the next layer on top of that lineage.**
|
||||
- **[#3664](https://github.com/bytedance/deer-flow/issues/3664) / PR [#3665](https://github.com/bytedance/deer-flow/pull/3665)** (merged) - added `user_id`/`user_role`/`oauth_*`/`run_id`/`tool_call_id` to `GuardrailRequest` and wired Gateway -> context -> middleware. **This is the plumbing our Layer 2 relies on.** Critically, #3665 explicitly scoped guardrails to *not* be an RBAC system (quoted in §11) - the RBAC brain is the deliberately-deferred gap this RFC adds.
|
||||
- **[#3672](https://github.com/bytedance/deer-flow/issues/3672) / PR [#3839](https://github.com/bytedance/deer-flow/pull/3839)** (merged) - propagate the bound connection owner's `role`/`oauth` into the guardrail context for IM/internal-auth runs. Documents the exact `user_role=None` gap for unbound channels ("If owner lookup fails, the run continues with role/oauth attribution unset") **that our `default_role` (§8) closes.**
|
||||
- **[#2507](https://github.com/bytedance/deer-flow/issues/2507) / RFC PR [#2504](https://github.com/bytedance/deer-flow/pull/2504)** (closed) - "Deferred MCP tools can execute before `tool_search` promotion." Proposed direction: *"Add an execution gate before tool execution."* This became `DeferredToolFilterMiddleware.wrap_tool_call`'s execution deny. **Our §7.2 two-layer design is consistent with this precedent** - the project already chose "execution gate" as the pattern for the deferred capability boundary.
|
||||
|
||||
### Security precedents - the gaps that motivate #3462
|
||||
|
||||
- **GHSA-4693 / [#2996](https://github.com/bytedance/deer-flow/pull/2996)** (closed) - proposed `@require_permission` for MCP/memory/skills routers after any authenticated user could RCE via MCP stdio config injection. The merged fix was **[#3855](https://github.com/bytedance/deer-flow/pull/3855)** (admin-gate skills) + **[#3425](https://github.com/bytedance/deer-flow/pull/3425)** (harden MCP config endpoint) - i.e. the project chose `require_admin_user` over fine-grained permissions for management surfaces. **This RFC respects that precedent** (§9 Phase 2, §12 Q6): management endpoints stay admin-gated; only ordinary routes migrate to the provider.
|
||||
- **[#1646](https://github.com/bytedance/deer-flow/issues/1646)**, **[#1648](https://github.com/bytedance/deer-flow/issues/1648)**, **[#2531](https://github.com/bytedance/deer-flow/issues/2531)** (open) - unauthenticated/over-broad MCP config + memory disclosure. Further evidence that resource-level authz is the open gap.
|
||||
|
||||
### Complementary (distinct axis, not overlapping)
|
||||
|
||||
- **[#2470](https://github.com/bytedance/deer-flow/issues/2470)** (open RFC) - "Pluggable auth *providers* with request-level hook." This is **authentication** (trusted-header/gateway SSO via an `AuthProvider` extension), and its non-goal #4 explicitly excludes authorization policy. **Complementary, not overlapping** - it decides *who you are*; this RFC decides *what you can do*.
|
||||
- **[#3322](https://github.com/bytedance/deer-flow/issues/3322)**, **[#3476](https://github.com/bytedance/deer-flow/issues/3476)**, **[#2761](https://github.com/bytedance/deer-flow/issues/2761)** (open) - **per-user credential** isolation (per-user MCP tokens, user connectors for GitHub/Linear, per-user model API keys). This is a *different axis* from per-*role* tool authorization: per-user creds = "act as this user on external service X"; this RFC = "may role Y use tool/model Z at all." The `Principal` (§5) and provider hook could eventually support per-user policies, but per-user credential plumbing is a separate effort.
|
||||
- **[#1721](https://github.com/bytedance/deer-flow/issues/1721)** (closed RFC) - the original user-authentication module design (the `AUTH_DESIGN.md` lineage). Scoped RBAC out as a non-goal ("当前用户角色只有 admin 和 user,尚未实现细粒度 RBAC"). **This RFC is the RBAC that #1721 deferred.**
|
||||
|
||||
### Other relevant security work
|
||||
|
||||
- **[#3630](https://github.com/bytedance/deer-flow/issues/3630) / [#3662](https://github.com/bytedance/deer-flow/pull/3662) / [#3661](https://github.com/bytedance/deer-flow/pull/3661)** (merged) - prompt-injection input sanitization + role isolation via system-message injection. Orthogonal defense; our Layer 1 (remove tools from the bound set) is the complementary capability-layer defense.
|
||||
- **[#3837](https://github.com/bytedance/deer-flow/pull/3837)** (merged) - persist guardrail interventions as run events. Our Layer 2 reuses this audit trail for free.
|
||||
- **[#3929](https://github.com/bytedance/deer-flow/issues/3929)** (open) - sandbox NodePort->ClusterIP (same author family of security hardening RFCs).
|
||||
|
||||
**Net takeaway:** the upstream has spent real effort plumbing identity into the guardrail execution point (#3665, #3839) and has explicitly deferred the RBAC policy brain. The two-layer design in this RFC is the natural next step the prior work points at - not a competing or redundant proposal.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user