124 lines
4.5 KiB
Python

"""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 resolved from trusted runtime identity context.
Identity fields mirror what ``inject_authenticated_user_context``
(``app/gateway/services.py``) already stamps into the run context, so the
provider sees one consistent identity shape. Layer 1 and the execution-time
guardrail adapter both use ``build_principal_from_context``; the adapter
rebuilds the value per request so it never caches stale runtime identity.
"""
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.
"""
...