feat(authz): add plugin resource targets, provenance, action and management enforcement (#5842)

* feat(authz): add plugin resource targets, provenance, action and management enforcement

PR1 of the extension-tool-authorization series
(docs/plans/2026-09-24-extension-tool-authorization-spec.md): plugin
authorization plumbing, no new author-facing declaration.

- `authz/plugin_targets.py` is the single encoder/validator for composite
  targets (`namespace/part`), with one validating composer behind three
  kind-checked constructors; invalid input raises instead of degrading.
- `authz/plugin_authz.py` is the harness decision layer (`plugin_action`,
  `plugin_management`, plus the batch filters the page slice will consume):
  disabled is a no-op, an explicit deny raises, and provider errors follow
  `fail_closed` like the sandbox gate.
- `POST /api/plugins/{ns}/actions/{name}` decides `plugin_action:invoke`
  after the action resolves and before the body is streamed.
- `deerflow_extension_api.auth` publishes `require_plugin_management` /
  `arequire_plugin_management` with two `app.state` resolver keys; the
  Gateway installs both. Contract version 0.2.3 -> 0.2.4.
- `tools/tool_provenance.py` records host-written plugin provenance for
  display (assembly descriptor, Layer-2 context later); no decision reads a
  label.
- Plugin providers use a `(config signature, loop key)` cache so a
  loop-affine provider is never handed to another loop or to a sync caller.
- RBAC gains `plugin_actions` / `plugin_management` aliases;
  `config_version` 47 -> 48 across the three mirrors.

Tests: new plugin target/provenance/action/management suites plus a
blocking-IO anchor whose offloads were reverted to prove red->green.

* fix(authz): decide plugin resources from the request's config snapshot

Review round 1 on PR #5842 (willem-bd).

P1 — the decision layer no longer reloads the configuration. The six
`authz/plugin_authz.py` functions take a required `app_config` snapshot (the
same request-scoped config the Gateway used to resolve the provider), and
`resolve_/aresolve_plugin_authorization` return it as a third element, so
`enabled`/`fail_closed` can never come from a second read that drifted from
the one that produced the provider. A snapshot the caller could not read at
all is *unavailable*, not disabled: it fails closed as
`authz.config_unavailable`. The Gateway's `_plugin_app_config` now separates
an absent `config.yaml` (no gate, today's behavior) from a config that exists
but cannot be read or validated (deny) instead of swallowing both through
`safe_app_config`.

P2 — `_validated_decision` requires `type(decision.allow) is bool` on both
the sync `authorize` and async `aauthorize` paths, so a provider returning
`AuthzDecision(allow="false")` is a malformed verdict that follows
`fail_closed` instead of being read as an allow.

Both fixes are covered by regression tests that were verified red against the
pre-fix behavior (P1: 200 vs 403 and resolver True vs False; P2: 12 cases
across fail_closed/fail_open and sync/async), plus tests pinning that an
absent config and `authorization.enabled: false` keep today's behavior.

* fix(authz): treat a request-time config absence as unavailable

Review round 2 on PR #5842 (willem-bd).

P1 — `_plugin_app_config[_async]` no longer maps a missing `config.yaml` to
`None` ("no gate"). A Gateway cannot have started without a readable config
(`app/gateway/app.py` reads it through this same accessor in `lifespan`), so
absence *during a request* means the configuration this process was running on
became unavailable — a hot reload, an atomic replace, a ConfigMap remount. It is
unavailable, not disabled: the error propagates,
`resolve_/aresolve_plugin_authorization` raise
`_PluginAuthorizationUnavailable(fail_closed=True)`, the action dispatcher
returns 403 and the management resolvers answer `False`. The dead
`(None, None, None)` allow path is gone, the resolvers return a non-optional
`AppConfig` as their third element, and callers key "authorization disabled" on
`provider is None` alone. This matches the rule the decision layer already
documented (`authz.config_unavailable`) and the public docs.

P2 — `_deny_reason_code` is total: a `reasons` that is not iterable degrades the
code to `authz.denied` instead of raising `TypeError` outside the guarded
provider path, which turned a denial into an unhandled error. It is deliberately
not folded into `_validated_decision`: `allow` was a real `bool`, so the verdict
is a valid denial, and treating it as malformed would flip it to an allow under
`fail_closed: false`.

Both fixes are covered by regression tests verified red against the pre-fix
behavior (P1: resolver True vs False, async `PermissionError` not raised, route
200 vs 403; P2: `TypeError: 'NoneType' object is not iterable` at
`plugin_authz.py:113`). The action-route tests in
`tests/test_plugin_contributions.py` now install a readable
`authorization.enabled: false` config instead of relying on a config-less
process, and `docs/full-stack-plugins.md` plus the PR1 decision log record the
new rule.

* fix(authz): apply the running policy when the plugin config is unreadable

Follow-up to f03d2ea9 (review round 2 on PR #5842, willem-bd).

The first attempt read the P1 finding as "absence is never disabled" and failed
every config read closed unconditionally. That is too broad for this repo: a
config-less host is deliberately treated as having no policy to apply
(`_get_route_authorization_config` falls back to a disabled config,
`sandbox_authz.safe_app_config` documents the same no-op for "CI runners and
direct-call tests"), and the rule turned the config-less e2e plugin host
(`extension_test_fixtures.bookmark_plugin_gateway`) into a 403 — the three
`bookmark-plugin.spec.ts` cases lost the save action. Reproduced locally:
`POST /api/plugins/community.bookmarks/actions/save` against that fixture host
returned 403 before, 200 after this change.

The rule is now "has this process loaded a configuration?", not "was the last
read successful?":

- No loaded config (`peek_loaded_app_config()`, new read-only accessor on
  `deerflow.config.app_config`) and no `config.yaml`: no policy to apply, so the
  gate stays a no-op — unchanged for CI runners, direct-call tests and hosts
  that mount only the plugins router.
- A loaded config whose file disappears or cannot be read: *unavailable*, never
  *disabled*. The resolvers raise `_PluginAuthorizationUnavailable` carrying the
  failure flag of the configuration this process runs on (authorization
  disabled there -> no gate; enabled -> that policy's `fail_closed`, `True` by
  default), so an enabled Gateway answers 403/False in the window instead of
  letting protected plugin actions and management routes through.
- A config file that exists but cannot be read or validated right now: fail
  closed when nothing is loaded (round 1's rule), otherwise the loaded policy
  decides.

`peek_loaded_app_config()` is preferred over a Gateway-side startup snapshot: it
reflects the last successfully loaded config, so a hot-reloaded policy is what
applies during the window, and there is no lifecycle hook to keep in sync.

Evidence: the three no-config/lost-config branches in
`tests/test_plugin_management_guard.py` and
`tests/test_plugin_action_authorization.py`, round 1's unreadable-config tests
unchanged, `test_peek_loaded_app_config_survives_a_missing_file` in
`tests/test_app_config_reload.py`, and the fixture-host probe above. Docs
(`docs/full-stack-plugins.md`, `reference.mdx` en/zh) and the PR1 decision log
state the final rule; the log records the superseded variant.

* fix(authz): keep a denial whose reasons raise while being read

* fix(authz): intersect plugin batch filters with the candidates

The plugin_page/plugin_management batch helpers returned the provider's
`filter_resources` answer verbatim, so a custom provider could hand the
projections a target that was never asked about ("*", another namespace,
a phantom page). Every other filter call site re-projects from its own
candidate list; do the same here, state the intersection on the provider
contract, and pin it with a regression test that fails on the old
pass-through.

* docs(plugins): map a management denial to 403 in the route example

The contributed-route example called `arequire_plugin_management` and let
the documented `PermissionError` escape. The Gateway installs no
`PermissionError` handler, so a route copied from the example answered 500
on a normal policy denial instead of 403. Both example routes now translate
the denial, and the surrounding bullets say why the translation is the
route's job.

* docs(extensions): point the 0.2.4 contract row at the PR that ships it

Every other row in the version-history table links the pull request that
shipped the version; 0.2.4 linked the authorization tracking issue instead.
Both the English and Chinese tables now link this PR.
This commit is contained in:
Zeren Wang 2026-09-25 08:46:12 +02:00 committed by GitHub
parent f8ca969330
commit 82671596f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 3558 additions and 41 deletions

View File

@ -4,6 +4,8 @@ from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager, suppress
from deerflow_extension_api import (
EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY,
EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY,
EXTENSION_PRINCIPAL_RESOLVER_KEY,
RUN_EVIDENCE_READER_RESOLVER_KEY,
ExtensionPrincipal,
@ -83,6 +85,84 @@ _SHUTDOWN_HOOK_TIMEOUT_SECONDS = 5.0
_RETRIEVAL_WARM_SHUTDOWN_TIMEOUT_SECONDS = 1.0
def _installed_plugin_namespace(request: Request, namespace: str) -> bool:
"""True when *namespace* is a plugin installed for this app instance."""
state = getattr(getattr(request, "app", None), "state", None)
loaded = getattr(state, "extensions", None)
for _, plugin in getattr(loaded, "plugins", ()):
if plugin.namespace == namespace:
return True
return False
def _resolve_extension_plugin_management(request: Request, namespace: str, scope: str = "read") -> bool | None:
"""Answer a contributed route's ``plugin_management`` question (sync callers).
``None`` means the host cannot answer — an unknown plugin or an anonymous
caller — and the public helper turns that into a denial. ``True``/``False``
are decisions: ``True`` when authorization is disabled, so a deployment that
turns authorization off does not start 403-ing enterprise routes, and
``False`` for a policy denial or a configuration that cannot be read (that
resolution layer is fail-closed).
Runs in the caller's thread: a FastAPI ``def`` endpoint is executed in the
thread pool, which is where a synchronous caller legitimately lives. An
async endpoint must use :func:`_resolve_extension_plugin_management_async`.
"""
from app.gateway.authz import _PluginAuthorizationUnavailable, resolve_plugin_authorization
from deerflow.authz.plugin_authz import PluginAuthorizationError, enforce_plugin_management
if not _installed_plugin_namespace(request, namespace):
return None
try:
provider, principal, app_config = resolve_plugin_authorization(request)
except _PluginAuthorizationUnavailable as unavailable:
return not unavailable.fail_closed
if provider is None:
return True
if principal is None:
return None
try:
enforce_plugin_management(
principal=principal,
app_config=app_config,
namespace=namespace,
write=scope == "write",
provider=provider,
)
except PluginAuthorizationError:
return False
return True
async def _resolve_extension_plugin_management_async(request: Request, namespace: str, scope: str = "read") -> bool | None:
"""Async counterpart of :func:`_resolve_extension_plugin_management`."""
from app.gateway.authz import _PluginAuthorizationUnavailable, aresolve_plugin_authorization
from deerflow.authz.plugin_authz import PluginAuthorizationError, aenforce_plugin_management
if not _installed_plugin_namespace(request, namespace):
return None
try:
provider, principal, app_config = await aresolve_plugin_authorization(request)
except _PluginAuthorizationUnavailable as unavailable:
return not unavailable.fail_closed
if provider is None:
return True
if principal is None:
return None
try:
await aenforce_plugin_management(
principal=principal,
app_config=app_config,
namespace=namespace,
write=scope == "write",
provider=provider,
)
except PluginAuthorizationError:
return False
return True
async def _ensure_admin_user(app: FastAPI) -> None:
"""Startup hook: handle first boot and migrate orphan threads otherwise.
@ -844,6 +924,12 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, _resolve_extension_principal)
# Contributed management routes ask the same provider the tool path uses,
# through a neutral ``bool | None`` answer. The host owns plugin-namespace
# validation and the request-scoped provider; the handler only asks.
setattr(app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY, _resolve_extension_plugin_management)
setattr(app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY, _resolve_extension_plugin_management_async)
def _resolve_extension_run_evidence_reader(request):
"""Bind evidence access to the principal stamped by AuthMiddleware."""
principal = _resolve_extension_principal(request)

View File

@ -47,7 +47,7 @@ from fastapi import HTTPException, Request
from deerflow.authz.principal import build_principal_from_context
from deerflow.authz.provider import AuthorizationProvider, AuthzDecision, AuthzRequest, Principal
from deerflow.authz.runtime import resolve_authorization_provider
from deerflow.authz.runtime import construct_authorization_provider, resolve_authorization_provider, resolve_authorization_provider_spec
from deerflow.config.authorization_config import AuthorizationConfig
if TYPE_CHECKING:
@ -487,6 +487,248 @@ def authorize_sandbox_for_request(
raise SandboxAuthorizationError(role=context.get("user_role")) from None
# --- Plugin resource authorization -------------------------------------------
#
# The plugin request paths deliberately do NOT reuse the route provider cache
# above: that cache is synchronous, constructs inline on the calling thread and
# keys on config identity alone, so a hit cannot prove the instance belongs to
# the caller's event loop. A plugin provider may be loop-affine (its
# ``__init__`` may create an asyncio client), so this cache keys on
# ``(config signature, loop key)`` and never hands a loop-built instance to a
# sync caller or to a different loop.
_PLUGIN_PROVIDER_SYNC_SLOT = object()
#: loop_key → (config signature, provider, owning loop or None for the sync slot).
_plugin_provider_cache: dict[object, tuple[str, AuthorizationProvider, asyncio.AbstractEventLoop | None]] = {}
class _PluginAuthorizationUnavailable(Exception):
"""Raised when the plugin provider cannot be resolved for a request.
Carries ``fail_closed`` so callers choose between deny and legacy allow
without re-reading config (mirrors ``_AuthorizationUnavailable``).
"""
def __init__(self, *, fail_closed: bool) -> None:
self.fail_closed = fail_closed
def _plugin_loaded_config() -> AppConfig | None:
"""The configuration this host is running on, or ``None`` if it has none.
``get_app_config()`` re-reads the file on every request (hot reload), so a
failed read needs the last successfully loaded value to tell a host that has
no configuration at all from one whose running policy just became
unreadable.
"""
from deerflow.config.app_config import peek_loaded_app_config
return peek_loaded_app_config()
def _plugin_config_failure_fail_closed() -> bool:
"""Failure flag for a plugin decision whose configuration cannot be read.
* No loaded config: the host never ran on a configuration, and the
``config.yaml``-less case returned no gate before reaching this call, so
this is a file that exists but cannot be read or validated right now. The
flag that would permit an allow is unreadable, so it fails closed
(review P1, round 1).
* A loaded config with authorization disabled: there is no gate to apply.
* A loaded config with authorization enabled: follow that policy's own
``fail_closed`` (``True`` by default), so a host that lost the config it
is running on answers like any other authorization failure instead of
reading the loss as "disabled" (review P1, round 2).
"""
loaded = _plugin_loaded_config()
if loaded is None:
return True
authz_config = getattr(loaded, "authorization", None)
if getattr(authz_config, "enabled", None) is not True:
return False
return getattr(authz_config, "fail_closed", False) is True
def _plugin_app_config() -> AppConfig | None:
"""Read the config snapshot for a plugin decision.
``None`` means this host has no configuration file *and* has never loaded
one, so there is no policy to apply — the same rule the route-scoped gates
use for environments without a ``config.yaml`` (CI runners, direct-call
tests, a host that mounts only the plugins router): authorization can only
be enabled through config.
A host running on a configuration never reads a failed load as "disabled".
Both the lost file (a hot reload, an atomic replace, a ConfigMap remount)
and a file that exists but cannot be read or validated right now propagate,
so the caller applies the failure policy of the policy it is running on
(:func:`_plugin_config_failure_fail_closed`).
"""
from deerflow.config.app_config import get_app_config
try:
return get_app_config()
except FileNotFoundError:
if _plugin_loaded_config() is None:
logger.debug("No `config.yaml` and no loaded config; the plugin authorization gate is a no-op", exc_info=True)
return None
raise
async def _plugin_app_config_async() -> AppConfig | None:
"""Off-loop :func:`_plugin_app_config` for async request paths."""
return await asyncio.to_thread(_plugin_app_config)
def _plugin_config_signature(config: AuthorizationConfig) -> str:
return repr(sorted(config.model_dump().items()))
def _store_plugin_provider(loop_key: object, loop: asyncio.AbstractEventLoop | None, signature: str, provider: AuthorizationProvider) -> None:
"""Publish one entry, dropping entries whose loop has closed (bounded growth)."""
for key, entry in list(_plugin_provider_cache.items()):
owner = entry[2]
if owner is not None and owner.is_closed():
_plugin_provider_cache.pop(key, None)
_plugin_provider_cache[loop_key] = (signature, provider, loop)
def _get_cached_plugin_provider_sync(config: AuthorizationConfig) -> AuthorizationProvider:
"""Resolve the provider for sync callers (and FastAPI ``def`` thread-pool workers)."""
signature = _plugin_config_signature(config)
cached = _plugin_provider_cache.get(_PLUGIN_PROVIDER_SYNC_SLOT)
if cached is not None and cached[0] == signature:
return cached[1]
provider = resolve_authorization_provider(config)
if provider is None:
raise ValueError("authorization is enabled but provider resolution returned None")
_store_plugin_provider(_PLUGIN_PROVIDER_SYNC_SLOT, None, signature, provider)
return provider
async def _aget_cached_plugin_provider(config: AuthorizationConfig) -> AuthorizationProvider:
"""Resolve the provider belonging to *this* loop; a miss discovers off-loop."""
loop = asyncio.get_running_loop()
loop_key = id(loop)
signature = _plugin_config_signature(config)
cached = _plugin_provider_cache.get(loop_key)
if cached is not None and cached[0] == signature:
return cached[1]
if getattr(config, "provider", None) is None:
# Nothing to import; preserve the resolver's own "enabled but unconfigured" error.
provider = resolve_authorization_provider(config)
else:
spec = await asyncio.to_thread(resolve_authorization_provider_spec, config)
# Construction stays on the calling loop: async providers may create
# loop-affine clients in ``__init__``.
provider = construct_authorization_provider(spec, config)
if provider is None:
raise ValueError("authorization is enabled but provider resolution returned None")
_store_plugin_provider(loop_key, loop, signature, provider)
return provider
def _plugin_request_principal(request: Request, authz_config: AuthorizationConfig) -> Principal | None:
"""Build the request-scoped Principal, or ``None`` when the caller is anonymous."""
user = getattr(getattr(request, "state", None), "user", None)
if user is None:
return None
return build_principal_from_context(
_route_authz_context(user, is_internal=_is_internal_caller(request, user)),
default_role=authz_config.default_role,
)
def resolve_plugin_authorization(request: Request) -> tuple[AuthorizationProvider | None, Principal | None, AppConfig | None]:
"""Return ``(provider, principal, app_config)`` for plugin resources, for sync callers.
``provider is None`` means authorization is disabled for this host — the
caller keeps today's behavior; ``app_config is None`` means there is no
configuration at all. The same ``app_config`` snapshot that produced the
provider is returned so the enforcement layer never re-reads the
configuration. Raises ``_PluginAuthorizationUnavailable`` (carrying
``fail_closed``) when the configuration this host is running on became
unreadable, or when the provider cannot be resolved.
"""
try:
app_config = _plugin_app_config()
except Exception:
logger.warning("App config unavailable for plugin authorization", exc_info=True)
raise _PluginAuthorizationUnavailable(fail_closed=_plugin_config_failure_fail_closed()) from None
if app_config is None:
return None, None, None
authz_config = getattr(app_config, "authorization", None)
if getattr(authz_config, "enabled", None) is not True:
return None, None, app_config
try:
provider = _get_cached_plugin_provider_sync(authz_config)
except Exception:
logger.warning("Failed to resolve authorization provider for plugin resources", exc_info=True)
raise _PluginAuthorizationUnavailable(fail_closed=getattr(authz_config, "fail_closed", False) is True) from None
return provider, _plugin_request_principal(request, authz_config), app_config
async def aresolve_plugin_authorization(request: Request) -> tuple[AuthorizationProvider | None, Principal | None, AppConfig | None]:
"""Async ``(provider, principal, app_config)`` for plugin resources.
Never returns the sync slot, and never performs synchronous config loading,
provider discovery or provider calls on the event loop. The returned
``app_config`` is the snapshot the provider was resolved from, so the
enforcement layer never re-reads the configuration.
"""
try:
app_config = await _plugin_app_config_async()
except Exception:
logger.warning("App config unavailable for plugin authorization", exc_info=True)
raise _PluginAuthorizationUnavailable(fail_closed=_plugin_config_failure_fail_closed()) from None
if app_config is None:
return None, None, None
authz_config = getattr(app_config, "authorization", None)
if getattr(authz_config, "enabled", None) is not True:
return None, None, app_config
try:
provider = await _aget_cached_plugin_provider(authz_config)
except Exception:
logger.warning("Failed to resolve authorization provider for plugin resources", exc_info=True)
raise _PluginAuthorizationUnavailable(fail_closed=getattr(authz_config, "fail_closed", False) is True) from None
return provider, _plugin_request_principal(request, authz_config), app_config
async def authorize_plugin_action_for_request(request: Request, *, namespace: str, action_name: str) -> None:
"""Authorize a registered plugin action before its handler runs (O2).
Returns normally when the action is permitted (or authorization is
disabled); raises ``HTTPException(403)`` on deny, and on a provider
resolution failure under ``fail_closed``.
"""
from deerflow.authz.plugin_authz import PluginAuthorizationError, aenforce_plugin_action
try:
provider, principal, app_config = await aresolve_plugin_authorization(request)
except _PluginAuthorizationUnavailable as unavailable:
if unavailable.fail_closed:
raise _plugin_action_denied() from None
return
if provider is None:
# Authorization is disabled: today's behavior.
return
try:
await aenforce_plugin_action(
principal=principal,
app_config=app_config,
namespace=namespace,
action_name=action_name,
provider=provider,
)
except PluginAuthorizationError as error:
raise _plugin_action_denied() from error
def _plugin_action_denied() -> HTTPException:
return HTTPException(status_code=403, detail="Plugin action not permitted for your role.")
@dataclass(slots=True)
class SandboxRequestLease:
"""One Gateway request's process-local use of a sandbox client."""

View File

@ -116,6 +116,13 @@ async def invoke_plugin_action(request: Request, namespace: str, action_name: st
raise HTTPException(503, "Plugin settings unavailable.") from exc
if settings["enabled"] is not True:
raise HTTPException(403, "Plugin disabled by administrator.")
# Authorize after the action is resolved (so the target is a host-validated
# declared name and an unknown action stays a 404) and before the body is
# streamed (so a denied caller cannot consume the input budget or reach the
# handler). No provider decision happens when authorization is disabled.
from app.gateway.authz import authorize_plugin_action_for_request
await authorize_plugin_action_for_request(request, namespace=namespace, action_name=action_name)
body = bytearray()
async for chunk in request.stream():
body.extend(chunk)

View File

@ -14,9 +14,13 @@ from deerflow_extension_api.assembly import (
ToolDescriptor,
)
from deerflow_extension_api.auth import (
EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY,
EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY,
EXTENSION_PRINCIPAL_RESOLVER_KEY,
ExtensionPrincipal,
arequire_plugin_management,
require_admin,
require_plugin_management,
resolve_principal,
)
from deerflow_extension_api.compaction import (
@ -82,7 +86,7 @@ from deerflow_extension_api.state import ExtensionData
#: Contract version. Before 1.0, minors may break and patches are additive.
#: From 1.0 on, bump the major for breaking changes.
API_VERSION = "0.2.3"
API_VERSION = "0.2.4"
__all__ = [
"ActionContext",
@ -94,6 +98,8 @@ __all__ = [
"ToolContext",
"SettingsField",
"API_VERSION",
"EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY",
"EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY",
"EXTENSION_PRINCIPAL_RESOLVER_KEY",
"EXTENSION_TASK_STORE_KEY",
"MESSAGE_CONTENT_KIND_KEY",
@ -140,10 +146,12 @@ __all__ = [
"canonical_hash",
"canonical_json",
"collect_release_policies",
"arequire_plugin_management",
"extension",
"provenance_kwargs",
"read_provenance",
"require_admin",
"require_plugin_management",
"resolve_principal",
"task_store_from_runtime",
]

View File

@ -11,11 +11,23 @@ package must not depend on a web framework.
from __future__ import annotations
import logging
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Literal, cast
logger = logging.getLogger(__name__)
EXTENSION_PRINCIPAL_RESOLVER_KEY = "deerflow_extension_principal_resolver"
EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY = "deerflow_extension_plugin_authz_resolver"
EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY = "deerflow_extension_plugin_authz_resolver_async"
# Mirrors the host's plugin-namespace registration rule
# (deerflow.config.plugin_settings). Kept local because this package must not
# import the host; tests/test_plugin_targets.py asserts every copy agrees.
_NAMESPACE_RE = re.compile(r"[a-z][a-z0-9_.-]{0,95}\Z")
_ManagementScope = Literal["read", "write"]
@dataclass(frozen=True)
@ -51,3 +63,80 @@ def require_admin(request: object) -> ExtensionPrincipal:
if principal is None or not principal.is_admin:
raise PermissionError("this endpoint requires an administrator account")
return principal
def _plugin_management_resolver(request: object, key: str) -> Callable | None:
app = getattr(request, "app", None)
state = getattr(app, "state", None)
resolver = getattr(state, key, None)
return resolver if callable(resolver) else None
def _plugin_management_scope(scope: str) -> _ManagementScope:
if scope not in ("read", "write"):
raise ValueError(f"scope must be 'read' or 'write', got {scope!r}")
return cast("_ManagementScope", scope)
def _require_plugin_namespace(namespace: object) -> str:
if not isinstance(namespace, str) or not _NAMESPACE_RE.fullmatch(namespace):
raise ValueError(f"invalid plugin namespace {namespace!r}")
return namespace
def require_plugin_management(request: object, namespace: str, *, scope: _ManagementScope = "read") -> ExtensionPrincipal:
"""Return the caller when it holds ``plugin_management`` *scope* for *namespace*.
The host answers through the resolver installed at
:data:`EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY`. This helper **fails closed**:
an absent, failing or ``None`` answer, an unknown registered namespace, or a
non-``True`` decision raises ``PermissionError`` — the same rule
:func:`require_admin` documents. A deployment with ``authorization.enabled:
false`` has the host answer ``True``, so turning authorization off does not
start rejecting enterprise routes; an enterprise that needs an
unconditional floor keeps calling :func:`require_admin` in addition.
Use the ``a`` variant from an async endpoint: this one runs in the caller's
thread and the host's sync answer may read configuration.
"""
scope = _plugin_management_scope(scope)
namespace = _require_plugin_namespace(namespace)
principal = resolve_principal(request)
if principal is None:
raise PermissionError("this endpoint requires an authenticated account")
resolver = _plugin_management_resolver(request, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY)
answer = None
if resolver is not None:
try:
answer = resolver(request, namespace, scope)
except Exception as exc: # noqa: BLE001 - an unanswerable question is a denial
logger.warning("extension plugin authorization resolver failed: %s", type(exc).__name__)
answer = None
if answer is not True:
raise PermissionError(f"plugin management ({scope}) is not permitted for {namespace}")
return principal
async def arequire_plugin_management(request: object, namespace: str, *, scope: _ManagementScope = "read") -> ExtensionPrincipal:
"""Async :func:`require_plugin_management`, for async endpoints.
Reads :data:`EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY` only. A sync
resolver installed without its async counterpart is not a fallback: running
it here would put the host's configuration read back on the event loop.
"""
scope = _plugin_management_scope(scope)
namespace = _require_plugin_namespace(namespace)
principal = resolve_principal(request)
if principal is None:
raise PermissionError("this endpoint requires an authenticated account")
resolver = _plugin_management_resolver(request, EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY)
answer = None
if resolver is not None:
try:
answer = await resolver(request, namespace, scope)
except Exception as exc: # noqa: BLE001 - an unanswerable question is a denial
logger.warning("extension plugin authorization resolver failed: %s", type(exc).__name__)
answer = None
if answer is not True:
raise PermissionError(f"plugin management ({scope}) is not permitted for {namespace}")
return principal

View File

@ -1,6 +1,6 @@
[project]
name = "deerflow-extension-api"
version = "0.2.3"
version = "0.2.4"
description = "Public contracts for DeerFlow extensions"
requires-python = ">=3.12"
# Keep the contract package import-light and independent from the host. Public

View File

@ -34,6 +34,7 @@ from deerflow_extension_api import (
from deerflow.sandbox.env_policy import is_blocked_env_name
from deerflow.tools.mcp_metadata import get_mcp_source, is_mcp_tool
from deerflow.tools.tool_provenance import resolve_tool_provenance
logger = logging.getLogger(__name__)
@ -211,21 +212,13 @@ def _tool_schema(tool: object) -> dict[str, object]:
def _tool_source(tool: object) -> str:
if is_mcp_tool(tool):
source = get_mcp_source(tool)
return f"mcp:{source['server_name']}" if source is not None else "mcp:unknown"
metadata = getattr(tool, "metadata", None)
if isinstance(metadata, dict):
declared = metadata.get("deerflow_tool_source")
if isinstance(declared, str) and declared:
return declared
callable_object = getattr(tool, "func", None) or getattr(tool, "coroutine", None)
module = getattr(callable_object, "__module__", "") or ""
if module.startswith("deerflow.tools.builtins") or module.startswith("deerflow.agents.memory"):
return "builtin"
if "skill" in module:
return "skill"
return "community" if module else "builtin"
"""Project the tool's display attribution (see `deerflow.tools.tool_provenance`).
Delegates so the descriptor identity and the execution-time label cannot
disagree. Labels for sources that carry no host-written tag are unchanged.
"""
provenance = resolve_tool_provenance(tool)
return provenance.source if provenance is not None else "builtin"
def describe_tool(tool: object) -> ToolDescriptor:

View File

@ -2,6 +2,22 @@
from deerflow.authz.adapter import GuardrailAuthorizationAdapter
from deerflow.authz.enforcement import filter_tools_by_authorization
from deerflow.authz.plugin_authz import (
PluginAuthorizationError,
aenforce_plugin_action,
aenforce_plugin_management,
afilter_plugin_management,
afilter_plugin_pages,
enforce_plugin_action,
enforce_plugin_management,
)
from deerflow.authz.plugin_targets import (
MANAGEMENT_READ_PART,
MANAGEMENT_WRITE_PART,
plugin_action_target,
plugin_management_target,
plugin_page_target,
)
from deerflow.authz.principal import build_principal_from_context, normalize_authz_attributes
from deerflow.authz.provider import AuthorizationProvider, AuthzDecision, AuthzReason, AuthzRequest, Principal
from deerflow.authz.rbac import RbacAuthorizationProvider
@ -15,12 +31,24 @@ __all__ = [
"AuthzRequest",
"AuthorizationProvider",
"GuardrailAuthorizationAdapter",
"MANAGEMENT_READ_PART",
"MANAGEMENT_WRITE_PART",
"PluginAuthorizationError",
"Principal",
"RbacAuthorizationProvider",
"aenforce_plugin_action",
"aenforce_plugin_management",
"afilter_plugin_management",
"afilter_plugin_pages",
"apply_tool_authorization",
"authorize_sandbox_execution",
"build_principal_from_context",
"enforce_plugin_action",
"enforce_plugin_management",
"filter_tools_by_authorization",
"normalize_authz_attributes",
"plugin_action_target",
"plugin_management_target",
"plugin_page_target",
"resolve_authorization_provider",
]

View File

@ -0,0 +1,398 @@
"""Authorization decisions for plugin resources (harness-side decision layer).
The plugin entry points — a registered backend action, a declared page, an
enterprise-contributed management route — ask the same configured
:class:`~deerflow.authz.provider.AuthorizationProvider` the tool path uses, with
the same trusted ``Principal`` construction. This module owns the *decision*;
the Gateway owns request scoping and the public extension guard lives in
``deerflow_extension_api.auth``.
Semantics, uniform across every function here:
* ``authorization.enabled is not True`` → no-op (allow). The identity check
mirrors :mod:`deerflow.authz.tool_filter`, so a ``Mock``/``SimpleNamespace``
app config cannot turn a non-bool into an enabled gate.
* The caller supplies ``app_config``: the *same* request-scoped snapshot it used
to resolve ``provider``. This module never reads the configuration itself, so
a config file that disappears or fails to parse mid-request cannot be mistaken
for a disabled policy. ``app_config=None`` means the caller could not read it
at all — *unavailable*, not disabled — and the check fails closed
(``authz.config_unavailable``) because the flag that would permit an allow is
unreadable.
* A caller-supplied ``provider`` is reused, so one request resolves the provider
once (the Gateway hands in the instance from its loop-keyed cache).
* An explicit deny raises :class:`PluginAuthorizationError`.
* A batch answer is intersected with the candidates it was asked about, so a
provider that names a target the host never offered cannot widen a projection.
* A provider exception, a resolution failure, a malformed decision (a non-
:class:`AuthzDecision`, or one whose ``allow`` is not a real ``bool``) or a
missing principal follows ``fail_closed``: raise the same error, or log a
warning and allow. This mirrors the sandbox / route-scoped semantics — not the
tool filter's silent-set behavior. A denial whose ``reasons`` cannot be read —
non-iterable, or an iterable that raises while consumed — is *not* malformed:
the verdict stands and only its ``reason_code`` degrades to ``authz.denied``, so
a bad reason list can never turn a deny into an allow or into a ``500``.
Execution placement: provider *discovery* is offloaded on the async path while
provider *construction* stays on the calling loop, because a valid async
provider may create loop-affine clients in ``__init__``; the configuration read
belongs to the caller (the Gateway performs it off-loop). The async batch
helpers hand the provider to a worker thread, which is why ``filter_resources``
must be thread-safe. The sync helpers serve genuinely synchronous callers (a
FastAPI ``def`` endpoint runs in the thread pool); an async endpoint uses the
``a*`` variant.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Iterable, Mapping
from typing import TYPE_CHECKING, Any
from deerflow.authz.plugin_targets import (
ManagementPart,
plugin_action_target,
plugin_management_target,
)
from deerflow.authz.provider import AuthorizationProvider, AuthzDecision, AuthzReason, AuthzRequest, Principal
from deerflow.authz.runtime import (
construct_authorization_provider,
resolve_authorization_provider,
resolve_authorization_provider_spec,
)
if TYPE_CHECKING:
from deerflow.config.app_config import AppConfig
logger = logging.getLogger(__name__)
RESOURCE_PLUGIN_ACTION = "plugin_action"
RESOURCE_PLUGIN_PAGE = "plugin_page"
RESOURCE_PLUGIN_MANAGEMENT = "plugin_management"
#: A batch question covers the whole candidate list, so it has no single target.
_BATCH_TARGET = "*"
class PluginAuthorizationError(Exception):
"""A plugin resource decision denied, or could not be answered under ``fail_closed``."""
def __init__(self, *, resource: str, target: str, reason_code: str, fail_closed: bool = False) -> None:
super().__init__(f"plugin authorization denied for {resource} '{target}' ({reason_code})")
self.resource = resource
self.target = target
self.reason_code = reason_code
self.fail_closed = fail_closed
def _authorization_request(
principal: Principal,
resource: str,
action: str,
target: str,
context: Mapping[str, Any] | None,
) -> AuthzRequest:
return AuthzRequest(
principal=principal,
resource=resource,
action=action,
target=target,
context=dict(context) if context else {},
)
def _unanswerable(*, resource: str, target: str, reason_code: str, fail_closed: bool) -> None:
"""Apply the failure policy for a question the host cannot answer."""
if fail_closed:
raise PluginAuthorizationError(resource=resource, target=target, reason_code=reason_code, fail_closed=True)
logger.warning(
"Plugin authorization for %s '%s' could not be answered (%s); allowing because fail_closed is false",
resource,
target,
reason_code,
)
def _deny_reason_code(decision: AuthzDecision) -> str:
"""Extract the provider's machine-readable denial code, tolerating bad ``reasons``.
``AuthzDecision`` is a plain dataclass, so a provider can return a verdict
whose ``reasons`` is not an iterable of :class:`AuthzReason` — or is an
iterable that raises from ``__iter__``/``__next__`` while being consumed. The
verdict is still a valid denial (``allow`` is a real ``bool``, and this runs
only after that check), so the denial stands and only the informative code
degrades: extraction must never raise, or a denial would escape as an
unhandled error instead of the configured authorization failure (a ``403``).
"""
try:
reasons = decision.reasons
if not isinstance(reasons, Iterable):
return "authz.denied"
for reason in reasons:
if isinstance(reason, AuthzReason) and isinstance(reason.code, str) and reason.code:
return reason.code
except Exception:
# A generator or custom iterable passes the ``Iterable`` check and can
# still raise while iterated; the verdict stays a denial.
logger.debug("Authorization denial carried unreadable reasons", exc_info=True)
return "authz.denied"
def _enabled_config(app_config: AppConfig | None) -> Any | None:
"""Return the authorization config when it is actually enabled, else ``None``."""
authz_config = getattr(app_config, "authorization", None) if app_config is not None else None
if authz_config is None or getattr(authz_config, "enabled", None) is not True:
return None
return authz_config
def _fail_closed(authz_config: Any) -> bool:
return getattr(authz_config, "fail_closed", False) is True
def _authorization_config(app_config: AppConfig | None, *, resource: str, target: str) -> Any | None:
"""Return the enabled authorization config, or ``None`` when policy is explicitly disabled.
``app_config is None`` means the caller could not read the configuration,
which is not the same as an explicitly disabled policy: the flag that would
permit an allow is unreadable, so the check fails closed.
"""
if app_config is None:
raise PluginAuthorizationError(resource=resource, target=target, reason_code="authz.config_unavailable", fail_closed=True)
return _enabled_config(app_config)
def _validated_decision(decision: object, *, method_name: str) -> AuthzDecision:
"""Reject a verdict that is not an ``AuthzDecision`` with a real ``bool`` allow.
``AuthzDecision`` is a plain dataclass, so a custom provider can return
``AuthzDecision(allow="false")``; the truthy string would otherwise be read
as an allow. A malformed verdict follows ``fail_closed`` like any other
provider failure.
"""
if not isinstance(decision, AuthzDecision) or type(decision.allow) is not bool:
raise TypeError(f"AuthorizationProvider.{method_name} must return AuthzDecision with a bool allow")
return decision
async def _aresolve_provider(authz_config: Any) -> AuthorizationProvider:
"""Resolve off-loop discovery, then construct on this loop (see module docstring)."""
if getattr(authz_config, "provider", None) is None:
# Nothing to import; preserve the resolver's own "enabled but unconfigured" error.
provider = resolve_authorization_provider(authz_config)
else:
spec = await asyncio.to_thread(resolve_authorization_provider_spec, authz_config)
provider = construct_authorization_provider(spec, authz_config)
if provider is None:
raise ValueError("authorization is enabled but provider resolution returned None")
return provider
def _enforce_single(*, principal, app_config, resource, action, target, provider, context) -> None:
authz_config = _authorization_config(app_config, resource=resource, target=target)
if authz_config is None:
return
fail_closed = _fail_closed(authz_config)
if principal is None:
_unanswerable(resource=resource, target=target, reason_code="authz.no_principal", fail_closed=fail_closed)
return
try:
active_provider = provider if provider is not None else resolve_authorization_provider(authz_config)
if active_provider is None:
raise ValueError("authorization is enabled but provider resolution returned None")
decision = _validated_decision(
active_provider.authorize(_authorization_request(principal, resource, action, target, context)),
method_name="authorize",
)
except PluginAuthorizationError:
raise
except Exception:
logger.warning("Authorization provider failed while checking %s '%s'", resource, target, exc_info=True)
_unanswerable(resource=resource, target=target, reason_code="authz.provider_error", fail_closed=fail_closed)
return
if not decision.allow:
raise PluginAuthorizationError(resource=resource, target=target, reason_code=_deny_reason_code(decision), fail_closed=fail_closed)
async def _aenforce_single(*, principal, app_config, resource, action, target, provider, context) -> None:
authz_config = _authorization_config(app_config, resource=resource, target=target)
if authz_config is None:
return
fail_closed = _fail_closed(authz_config)
if principal is None:
_unanswerable(resource=resource, target=target, reason_code="authz.no_principal", fail_closed=fail_closed)
return
try:
active_provider = provider if provider is not None else await _aresolve_provider(authz_config)
decision = _validated_decision(
await active_provider.aauthorize(_authorization_request(principal, resource, action, target, context)),
method_name="aauthorize",
)
except PluginAuthorizationError:
raise
except Exception:
logger.warning("Authorization provider failed while checking %s '%s'", resource, target, exc_info=True)
_unanswerable(resource=resource, target=target, reason_code="authz.provider_error", fail_closed=fail_closed)
return
if not decision.allow:
raise PluginAuthorizationError(resource=resource, target=target, reason_code=_deny_reason_code(decision), fail_closed=fail_closed)
async def _afilter(*, principal, app_config, resource, candidates: Iterable[str], provider) -> frozenset[str]:
"""Return the subset of *candidates* the provider allows (one round trip).
The result is a subset of *candidates* on every path, including the provider
answer: ``filter_resources`` is documented to return "only the allowed
subset", but it is a plain method on a custom provider, so an answer naming a
target that was never asked about (``"*"``, another namespace, a phantom
page) is intersected away rather than projected as visible.
"""
candidate_list = list(candidates)
authz_config = _authorization_config(app_config, resource=resource, target=_BATCH_TARGET)
if authz_config is None:
return frozenset(candidate_list)
if not candidate_list:
# No candidate means no provider round trip (the projection's cost rule).
return frozenset()
fail_closed = _fail_closed(authz_config)
if principal is None:
_unanswerable(resource=resource, target=_BATCH_TARGET, reason_code="authz.no_principal", fail_closed=fail_closed)
return frozenset(candidate_list)
try:
active_provider = provider if provider is not None else await _aresolve_provider(authz_config)
# filter_resources is synchronous and may do blocking work; keep it off the loop.
allowed = await asyncio.to_thread(active_provider.filter_resources, principal, resource, candidate_list)
if not isinstance(allowed, list) or any(not isinstance(name, str) for name in allowed):
raise TypeError("AuthorizationProvider.filter_resources must return list[str]")
except PluginAuthorizationError:
raise
except Exception:
logger.warning("Authorization provider failed while filtering %s candidates", resource, exc_info=True)
_unanswerable(resource=resource, target=_BATCH_TARGET, reason_code="authz.provider_error", fail_closed=fail_closed)
return frozenset(candidate_list)
return frozenset(allowed) & frozenset(candidate_list)
def enforce_plugin_action(
*,
principal: Principal | None,
app_config: AppConfig | None,
namespace: str,
action_name: str,
provider: AuthorizationProvider | None = None,
context: Mapping[str, Any] | None = None,
) -> None:
"""Allow or raise for ``plugin_action``/``invoke`` on ``namespace/action_name``."""
target = plugin_action_target(namespace, action_name)
_enforce_single(
principal=principal,
app_config=app_config,
resource=RESOURCE_PLUGIN_ACTION,
action="invoke",
target=target,
provider=provider,
context=context,
)
async def aenforce_plugin_action(
*,
principal: Principal | None,
app_config: AppConfig | None,
namespace: str,
action_name: str,
provider: AuthorizationProvider | None = None,
context: Mapping[str, Any] | None = None,
) -> None:
"""Async :func:`enforce_plugin_action` for async callers (the action route)."""
target = plugin_action_target(namespace, action_name)
await _aenforce_single(
principal=principal,
app_config=app_config,
resource=RESOURCE_PLUGIN_ACTION,
action="invoke",
target=target,
provider=provider,
context=context,
)
def enforce_plugin_management(
*,
principal: Principal | None,
app_config: AppConfig | None,
namespace: str,
write: bool,
provider: AuthorizationProvider | None = None,
context: Mapping[str, Any] | None = None,
) -> None:
"""Allow or raise for ``plugin_management`` read/write on *namespace*."""
part: ManagementPart = "permissions.write" if write else "permissions.read"
target = plugin_management_target(namespace, part)
_enforce_single(
principal=principal,
app_config=app_config,
resource=RESOURCE_PLUGIN_MANAGEMENT,
action="write" if write else "read",
target=target,
provider=provider,
context=context,
)
async def aenforce_plugin_management(
*,
principal: Principal | None,
app_config: AppConfig | None,
namespace: str,
write: bool,
provider: AuthorizationProvider | None = None,
context: Mapping[str, Any] | None = None,
) -> None:
"""Async :func:`enforce_plugin_management` for async callers."""
part: ManagementPart = "permissions.write" if write else "permissions.read"
target = plugin_management_target(namespace, part)
await _aenforce_single(
principal=principal,
app_config=app_config,
resource=RESOURCE_PLUGIN_MANAGEMENT,
action="write" if write else "read",
target=target,
provider=provider,
context=context,
)
async def afilter_plugin_pages(
*,
principal: Principal | None,
app_config: AppConfig | None,
candidates: Iterable[str],
provider: AuthorizationProvider | None = None,
) -> frozenset[str]:
"""Return the visible subset of ``plugin_page`` targets (one provider round trip)."""
return await _afilter(
principal=principal,
app_config=app_config,
resource=RESOURCE_PLUGIN_PAGE,
candidates=candidates,
provider=provider,
)
async def afilter_plugin_management(
*,
principal: Principal | None,
app_config: AppConfig | None,
candidates: Iterable[str],
provider: AuthorizationProvider | None = None,
) -> frozenset[str]:
"""Return the permitted subset of ``plugin_management`` targets (one round trip)."""
return await _afilter(
principal=principal,
app_config=app_config,
resource=RESOURCE_PLUGIN_MANAGEMENT,
candidates=candidates,
provider=provider,
)

View File

@ -0,0 +1,77 @@
"""Canonical encoding for composite plugin authorization targets.
One module owns every ``target`` string a plugin decision is asked about, so the
producers (the registered-action dispatcher, the page projection, the management
guards, the resource catalog) and the readers (providers, enterprise policy)
cannot disagree about encoding or validation.
A target is ``"{namespace}/{part}"``: the left side is always a host-validated
plugin namespace, the right side is the kind-specific component validated
against the charset of the layer that already owns it. The namespace charset
excludes ``/``, so the composite is unambiguous, and **no call site may build a
target by string concatenation** — every producer calls one of the three
constructors below, each of which raises :class:`PluginTargetError` instead of
returning a best-effort string.
Actions are carried for protocol fidelity only: the built-in RBAC provider
ignores ``AuthzRequest.action``, so read/write authority is separated by
*different targets*, never by different actions on one policy.
"""
from __future__ import annotations
import re
from typing import Literal
# Mirrors deerflow.config.plugin_settings (host registration rule) byte for byte.
_NAMESPACE_PATTERN = r"[a-z][a-z0-9_.-]{0,95}"
# Mirrors deerflow.extensions.registry's registered-backend-action rule.
_ACTION_PATTERN = r"[a-z][a-z0-9_-]{0,63}"
# Mirrors the browser module's surface-id rule (frontend/src/core/extensions/registry.ts).
_SURFACE_PATTERN = r"[a-z][a-z0-9-]{0,63}"
MANAGEMENT_READ_PART = "permissions.read"
MANAGEMENT_WRITE_PART = "permissions.write"
#: The only accepted management parts. Read and write are different targets so
#: the built-in provider can distinguish them (it ignores ``action``).
ManagementPart = Literal["permissions.read", "permissions.write"]
_NAMESPACE_RE = re.compile(_NAMESPACE_PATTERN)
_ACTION_RE = re.compile(_ACTION_PATTERN)
_SURFACE_RE = re.compile(_SURFACE_PATTERN)
_MANAGEMENT_RE = re.compile(f"(?:{re.escape(MANAGEMENT_READ_PART)}|{re.escape(MANAGEMENT_WRITE_PART)})")
class PluginTargetError(ValueError):
"""A plugin resource target could not be composed from host-validated components."""
def _join(namespace: object, part: object, *, field: str, pattern: re.Pattern[str]) -> str:
"""Compose ``namespace/part`` after validating both halves, or raise."""
if not isinstance(namespace, str) or not _NAMESPACE_RE.fullmatch(namespace):
raise PluginTargetError(f"invalid plugin namespace {namespace!r}; expected {_NAMESPACE_PATTERN}")
if not isinstance(part, str) or not pattern.fullmatch(part):
raise PluginTargetError(f"invalid plugin {field} {part!r}; expected {pattern.pattern}")
return f"{namespace}/{part}"
def plugin_action_target(namespace: str, action_name: str) -> str:
"""Target for a registered plugin backend action (``resource="plugin_action"``)."""
return _join(namespace, action_name, field="action name", pattern=_ACTION_RE)
def plugin_page_target(namespace: str, surface_id: str) -> str:
"""Target for a declared plugin page (``resource="plugin_page"``).
``plugin_action_target(ns, "reports")`` and ``plugin_page_target(ns, "reports")``
are deliberately byte-identical: the resource differs, the target does not.
Readers therefore look resources up by ``(resource, target)``, never by
target alone.
"""
return _join(namespace, surface_id, field="surface id", pattern=_SURFACE_RE)
def plugin_management_target(namespace: str, part: ManagementPart) -> str:
"""Target for a management read/write check (``resource="plugin_management"``)."""
return _join(namespace, part, field="management part", pattern=_MANAGEMENT_RE)

View File

@ -119,5 +119,13 @@ class AuthorizationProvider(Protocol):
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.
The answer can only narrow *candidates*: every caller intersects it with
the list it asked about, so naming a target that was never a candidate
grants nothing.
Implementations must be thread-safe: an async caller offloads this
synchronous method to a worker thread, so it may run while the event
loop keeps serving other work.
"""
...

View File

@ -30,6 +30,15 @@ _RESOURCE_POLICY_KEYS: dict[str, str] = {
"sandbox": "sandbox",
"mcp_server": "mcp_servers",
"route": "routes",
# Plugin resources. The left side is the request ``resource`` and the right
# side is the ``config.yaml`` key. ``plugin_management`` is genuinely
# self-mapped (like ``sandbox``), so a config key of the same name is legal;
# ``plugin_action`` is not, and using it as a config key is rejected at
# construction. A resource with no policy key is unrestricted, which is the
# documented non-breaking path for deployments that enable authorization
# without listing these keys.
"plugin_action": "plugin_actions",
"plugin_management": "plugin_management",
}
_ALL = object() # sentinel meaning "allow all candidates"

View File

@ -783,6 +783,23 @@ def peek_current_app_config() -> AppConfig | None:
return _current_app_config.get()
def peek_loaded_app_config() -> AppConfig | None:
"""Return the configuration this process has loaded, without touching the filesystem.
The runtime-scoped override wins when one is active; otherwise this is the
cached singleton ``get_app_config()`` last loaded (or ``set_app_config()``
installed). ``None`` means no configuration has ever been loaded in this
process, which is what a host without a ``config.yaml`` looks like — as
opposed to a host that *is* running on a config and can no longer read the
file, where callers need the loaded value to tell "unavailable" from
"never configured".
"""
override = _current_app_config.get()
if override is not None:
return override
return _app_config
def push_current_app_config(config: AppConfig) -> None:
"""Push a runtime-scoped AppConfig override for the current execution context."""
stack = _current_app_config_stack.get()

View File

@ -19,6 +19,7 @@ from langchain_core.tools import StructuredTool, ToolException
from deerflow.config.plugin_settings import defaults
from deerflow.runtime.user_context import resolve_runtime_user_id
from deerflow.tools.tool_provenance import tag_plugin_tool
logger = logging.getLogger(__name__)
@ -87,7 +88,9 @@ def _build_tool(source, plugin, declaration):
logger.warning("Plugin tool failed: %s/%s (%s)", plugin.namespace, declaration.name, type(exc).__name__)
raise ToolException("Plugin tool unavailable or input rejected.") from None
return StructuredTool(name=plugin_tool_name(plugin.namespace, declaration.name), description=declaration.description, args_schema=schema, coroutine=invoke, handle_tool_error=True)
tool = StructuredTool(name=plugin_tool_name(plugin.namespace, declaration.name), description=declaration.description, args_schema=schema, coroutine=invoke, handle_tool_error=True)
tag_plugin_tool(tool, namespace=plugin.namespace, declaration=declaration.name, installation=source)
return tool
def build_plugin_tools(loaded, *, groups=None, reserved_names=()):

View File

@ -0,0 +1,218 @@
"""Host-recorded tool provenance.
Two consumers with different trust needs share this module:
* **Display attribution** — which component a tool came from, for assembly
descriptors, traces and enterprise context. Best effort and consistency
checked; a provenance label is **never** a trust input.
* **Exemption identity** — the one host decision that needs certainty (the
Layer-2 infrastructure-tool exemption) compares the concrete bound tool
object, not a label. That reference travels on
``GuardrailRequest.tool_identity``; this module decides nothing.
Only two sources write a tag, both host-written at construction time: MCP tools
(:mod:`deerflow.tools.mcp_metadata`, written by the loader) and plugin tools
(:func:`tag_plugin_tool`, written by ``deerflow.extensions.plugin_tools``).
Every other source keeps a heuristic label computed here, and the resolution
order is part of the contract:
``mcp flag`` → ``plugin tag`` → declared ``deerflow_tool_source`` → module
heuristic → ``builtin`` when the callable exposes no module.
Some host-owned sources are labeled ``community`` by that heuristic
(task-continuity tools, project-shelf tools, config- and middleware-declared
tools); those labels are display-only and unchanged.
"""
from __future__ import annotations
import logging
import re
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from deerflow.tools.mcp_metadata import get_mcp_source, is_mcp_tool
logger = logging.getLogger(__name__)
PLUGIN_TOOL_METADATA_KEY = "deerflow_plugin"
PLUGIN_TOOL_SOURCE_METADATA_KEY = "deerflow_plugin_source"
DECLARED_TOOL_SOURCE_METADATA_KEY = "deerflow_tool_source"
# Mirrors the host plugin-namespace registration rule
# (deerflow.config.plugin_settings). Independent copy on purpose: this module is
# a leaf that must not import the extension registry, and
# tests/test_plugin_targets.py asserts all three copies agree on one sample set.
_NAMESPACE_PATTERN = r"[a-z][a-z0-9_.-]{0,95}"
_NAMESPACE_RE = re.compile(_NAMESPACE_PATTERN)
# Order of the optional fields in the plain-mapping form handed to enterprise
# consumers (GuardrailRequest.tool_provenance, AuthzRequest.context).
_OPTIONAL_CONTEXT_FIELDS = ("namespace", "declaration", "installation", "operation", "mcp_server", "mcp_transport")
@dataclass(frozen=True, slots=True)
class ToolProvenance:
"""Where a bound tool came from, for display and enterprise context."""
source: str
"""``"plugin:<namespace>"`` | ``"mcp:<server>"`` | ``"builtin"`` | ``"skill"`` | ``"community"``."""
namespace: str | None = None
declaration: str | None = None
"""The plugin's raw ``ModelTool.name`` (not the generated bound tool name)."""
installation: str | None = None
"""The contributing ``ExtensionSpec`` source string."""
operation: str | None = None
"""The plugin's declared shared operation, when it declared one."""
mcp_server: str | None = None
mcp_transport: str | None = None
def tag_plugin_tool(
tool: object,
*,
namespace: str,
declaration: str,
installation: str,
operation: str | None = None,
) -> None:
"""Record host-side plugin provenance on ``tool`` (mutates in place).
Only the host calls this, at plugin-tool construction, with values the
registry already validated. ``ModelTool`` carries no metadata field, so a
plugin cannot inject a ``deerflow_*`` tag through its declaration.
"""
source: dict[str, Any] = {
"namespace": namespace,
"declaration": declaration,
"installation": installation,
}
if operation is not None:
source["operation"] = operation
tool.metadata = {
**(getattr(tool, "metadata", None) or {}),
PLUGIN_TOOL_METADATA_KEY: True,
PLUGIN_TOOL_SOURCE_METADATA_KEY: source,
}
def is_plugin_tool(tool: object) -> bool:
"""True when ``tool`` carries the plugin tag written by :func:`tag_plugin_tool`."""
metadata = getattr(tool, "metadata", None)
return isinstance(metadata, Mapping) and metadata.get(PLUGIN_TOOL_METADATA_KEY) is True
def get_plugin_source(tool: object) -> dict[str, Any] | None:
"""Return the structurally valid plugin tag, or ``None``.
Valid means the flag is set, every required field is a non-empty string, the
namespace matches the host charset, and ``operation`` is a non-empty string
when present. The tag's *name consistency* with the tool is checked by
:func:`resolve_tool_provenance`, which owns the ``tool.name`` comparison.
"""
metadata = getattr(tool, "metadata", None)
if not isinstance(metadata, Mapping) or metadata.get(PLUGIN_TOOL_METADATA_KEY) is not True:
return None
source = metadata.get(PLUGIN_TOOL_SOURCE_METADATA_KEY)
if not isinstance(source, Mapping):
return None
namespace = source.get("namespace")
declaration = source.get("declaration")
installation = source.get("installation")
if not all(isinstance(value, str) and value for value in (namespace, declaration, installation)):
return None
if not _NAMESPACE_RE.fullmatch(namespace):
return None
resolved: dict[str, Any] = {
"namespace": namespace,
"declaration": declaration,
"installation": installation,
}
operation = source.get("operation")
if operation is not None:
if not isinstance(operation, str) or not operation:
return None
resolved["operation"] = operation
return resolved
def _callable_module(tool: object) -> str:
callable_object = getattr(tool, "func", None) or getattr(tool, "coroutine", None)
return getattr(callable_object, "__module__", "") or ""
def resolve_tool_provenance(tool: object | None) -> ToolProvenance | None:
"""Resolve *tool*'s provenance, or ``None`` when there is no tool at all.
Every tool without a tag still resolves to a label, so a label lookup is
never ``None`` for a real tool.
"""
if tool is None:
return None
if is_mcp_tool(tool):
source = get_mcp_source(tool)
if source is None:
return ToolProvenance(source="mcp:unknown")
return ToolProvenance(
source=f"mcp:{source['server_name']}",
mcp_server=source["server_name"],
mcp_transport=source["transport"],
)
plugin = get_plugin_source(tool)
if plugin is not None:
# Local import: plugin_tools tags its tools through this module, so a
# module-level import here would be a cycle. The consistency check runs
# only for tagged tools, which are host-built plugin tools.
from deerflow.extensions.plugin_tools import plugin_tool_name
if getattr(tool, "name", None) == plugin_tool_name(plugin["namespace"], plugin["declaration"]):
return ToolProvenance(
source=f"plugin:{plugin['namespace']}",
namespace=plugin["namespace"],
declaration=plugin["declaration"],
installation=plugin["installation"],
operation=plugin.get("operation"),
)
# A mis-attributed tag is dropped rather than mislabeling the tool. This
# is a consistency check, not an authenticity check: code able to forge
# the metadata can forge the name with it. It exists so a tag that does
# not describe *this* tool never becomes a label.
logger.warning(
"Dropping inconsistent plugin provenance tag on tool %r (declared %s/%s)",
getattr(tool, "name", tool),
plugin["namespace"],
plugin["declaration"],
)
metadata = getattr(tool, "metadata", None)
if isinstance(metadata, Mapping):
declared = metadata.get(DECLARED_TOOL_SOURCE_METADATA_KEY)
if isinstance(declared, str) and declared:
return ToolProvenance(source=declared)
module = _callable_module(tool)
if module.startswith("deerflow.tools.builtins") or module.startswith("deerflow.agents.memory"):
return ToolProvenance(source="builtin")
if "skill" in module:
return ToolProvenance(source="skill")
return ToolProvenance(source="community" if module else "builtin")
def tool_provenance_context(tool: object | None) -> dict[str, Any]:
"""Plain-mapping form of :func:`resolve_tool_provenance` for request contexts."""
provenance = resolve_tool_provenance(tool)
if provenance is None:
return {}
context: dict[str, Any] = {"source": provenance.source}
for field in _OPTIONAL_CONTEXT_FIELDS:
value = getattr(provenance, field)
if value is not None:
context[field] = value
return context

View File

@ -13,7 +13,7 @@ dependencies = [
# the contract version it implements, extensions declare ranges. A range
# here would let pip resolve a newer contract package than this harness
# implements, making newer extensions look supported at runtime.
"deerflow-extension-api==0.2.3",
"deerflow-extension-api==0.2.4",
"dotenv>=0.9.9",
"exa-py>=1.0.0",
"httpx>=0.28.0",

View File

@ -0,0 +1,186 @@
"""Plugin authorization must keep its blocking work off the async event loop.
Anchors the placement contract of ``authz/plugin_authz.py`` and
``app/gateway/authz.py``: config loading, provider *discovery* and the
synchronous ``filter_resources`` call all run in a worker, while provider
*construction* and ``aauthorize`` stay on the calling loop. Each test drives a
real production entry point with a probe that performs genuine blocking file IO,
so the gate fires if the offload is reverted.
Each test records that its probe really ran, so an anchor cannot pass because
the production path stopped calling it. `test_gate_smoke.py` protects the gate
itself from silently losing teeth.
"""
from __future__ import annotations
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
from uuid import uuid4
import pytest
from deerflow_extension_api.auth import (
EXTENSION_PRINCIPAL_RESOLVER_KEY,
ExtensionPrincipal,
)
from deerflow_extension_api.plugins import BackendAction, PluginContribution
from fastapi import HTTPException
from starlette.requests import Request
from app.gateway import authz as gateway_authz
from app.gateway.app import _resolve_extension_plugin_management_async
from app.gateway.auth_disabled import AUTH_SOURCE_SESSION
from app.gateway.routers.plugins import invoke_plugin_action
from deerflow.authz.plugin_authz import afilter_plugin_management
from deerflow.authz.provider import Principal
from deerflow.authz.rbac import RbacAuthorizationProvider
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
from deerflow.config.authorization_config import AuthorizationConfig, AuthorizationProviderConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.extensions.registry import ExtensionRegistry
pytestmark = pytest.mark.asyncio
NAMESPACE = "community.check"
RBAC = "deerflow.authz.rbac:RbacAuthorizationProvider"
def _blocking_probe(tmp_path: Path) -> Path:
path = tmp_path / "plugin-authz-blocking-probe"
path.write_text("probe", encoding="utf-8")
return path
def _app_config(*, roles: dict, provider_use: str = RBAC, provider_config: dict | None = None) -> AppConfig:
return AppConfig(
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"),
authorization=AuthorizationConfig(
enabled=True,
fail_closed=True,
default_role="user",
provider=AuthorizationProviderConfig(use=provider_use, config={"roles": roles} if provider_config is None else provider_config),
),
)
def _plugin_extensions() -> object:
async def check(payload, context): # pragma: no cover - the decisions under test deny first
return {}
plugin = PluginContribution(namespace=NAMESPACE, title="Check", enabled=True, backend=(BackendAction("check", check),))
registry = ExtensionRegistry()
with registry.attributed_to("test:install"):
registry.plugin(plugin)
return registry.build()
def _unknown_request(*, extensions: object, user_id: str = "user-1") -> SimpleNamespace:
return SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(extensions=extensions)),
state=SimpleNamespace(user=SimpleNamespace(id=user_id, system_role="user", oauth_provider=None, oauth_id=None), auth_source=AUTH_SOURCE_SESSION),
headers={},
)
def _action_request(extensions: object) -> Request:
"""A real Starlette Request for the action route, without an ASGI round trip."""
app = SimpleNamespace(state=SimpleNamespace(extensions=extensions))
user = SimpleNamespace(id=uuid4(), system_role="user", oauth_provider=None, oauth_id=None)
request = Request({"type": "http", "app": app, "headers": [], "method": "POST"})
request.state.user = user
request.state.auth_source = AUTH_SOURCE_SESSION
setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, lambda incoming: ExtensionPrincipal(str(incoming.state.user.id)))
return request
@pytest.fixture(autouse=True)
def _isolated_authorization():
reset_app_config()
gateway_authz._plugin_provider_cache.clear()
yield
gateway_authz._plugin_provider_cache.clear()
reset_app_config()
async def test_action_route_offloads_config_load_and_provider_discovery(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Deny path of POST /api/plugins/{ns}/actions/{name} (the real route function)."""
probe = _blocking_probe(tmp_path)
app_config = _app_config(roles={"user": {"plugin_actions": {"allow": []}}})
set_app_config(app_config)
config_loads: list[str] = []
discoveries: list[str] = []
def blocking_config_load() -> AppConfig:
probe.read_text(encoding="utf-8")
config_loads.append("loaded")
return app_config
discover = gateway_authz.resolve_authorization_provider_spec
def blocking_discovery(config: AuthorizationConfig):
probe.read_text(encoding="utf-8")
discoveries.append("discovered")
return discover(config)
monkeypatch.setattr(gateway_authz, "_plugin_app_config", blocking_config_load)
monkeypatch.setattr(gateway_authz, "resolve_authorization_provider_spec", blocking_discovery)
request = _action_request(_plugin_extensions())
with pytest.raises(HTTPException) as error:
await invoke_plugin_action(request, namespace=NAMESPACE, action_name="check")
assert error.value.status_code == 403
# Both blocking helpers really ran — inside workers, or the gate would have raised.
assert config_loads
assert discoveries == ["discovered"]
async def test_async_management_resolver_offloads_the_same_resolution(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""The resolver installed at EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY."""
probe = _blocking_probe(tmp_path)
app_config = _app_config(roles={"user": {"plugin_management": {"allow": []}}})
set_app_config(app_config)
config_loads: list[str] = []
def blocking_config_load() -> AppConfig:
probe.read_text(encoding="utf-8")
config_loads.append("loaded")
return app_config
discover = gateway_authz.resolve_authorization_provider_spec
def blocking_discovery(config: AuthorizationConfig):
probe.read_text(encoding="utf-8")
return discover(config)
monkeypatch.setattr(gateway_authz, "_plugin_app_config", blocking_config_load)
monkeypatch.setattr(gateway_authz, "resolve_authorization_provider_spec", blocking_discovery)
answer = await _resolve_extension_plugin_management_async(_unknown_request(extensions=_plugin_extensions()), NAMESPACE, "read")
assert answer is False # denied by policy, decided without blocking the loop
assert config_loads # reached inside a worker, or the gate would have raised
async def test_batch_decision_offloads_the_synchronous_provider_call(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""``filter_resources`` is synchronous, so the batch helpers hop to a worker."""
probe = _blocking_probe(tmp_path)
module = ModuleType("plugin_blocking_io_test_provider")
class BlockingFilterProvider(RbacAuthorizationProvider):
def filter_resources(self, principal, resource_type, candidates):
probe.read_text(encoding="utf-8")
return list(candidates)
module.BlockingFilterProvider = BlockingFilterProvider
monkeypatch.setitem(sys.modules, module.__name__, module)
app_config = _app_config(roles={"user": {}}, provider_use=f"{module.__name__}:BlockingFilterProvider", provider_config={"roles": {"user": {}}})
candidates = [f"{NAMESPACE}/permissions.read"]
allowed = await afilter_plugin_management(principal=Principal(user_id="user-1", role="user"), app_config=app_config, candidates=candidates)
assert allowed == frozenset(candidates)

View File

@ -11,7 +11,7 @@ from pydantic import ValidationError
import deerflow.config.app_config as app_config_module
from deerflow.config.acp_config import load_acp_config_from_dict
from deerflow.config.agents_api_config import get_agents_api_config, load_agents_api_config_from_dict
from deerflow.config.app_config import AppConfig, get_app_config, reset_app_config
from deerflow.config.app_config import AppConfig, get_app_config, peek_loaded_app_config, reset_app_config
from deerflow.config.checkpointer_config import get_checkpointer_config, load_checkpointer_config_from_dict
from deerflow.config.database_config import DatabaseConfig
from deerflow.config.guardrails_config import get_guardrails_config, load_guardrails_config_from_dict
@ -461,6 +461,39 @@ def test_get_app_config_reloads_when_file_changes(tmp_path, monkeypatch):
reset_app_config()
def test_peek_loaded_app_config_survives_a_missing_file(tmp_path, monkeypatch):
"""A host running on a config keeps that value after the file disappears.
This is the distinction ``app.gateway.authz`` needs for the plugin
authorization gate: a failed ``get_app_config()`` under a host that has a
configuration means "the policy I am running on is unreadable right now",
not "authorization was never configured".
"""
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
_write_config(config_path, model_name="model-a", supports_thinking=False)
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
reset_app_config()
try:
assert peek_loaded_app_config() is None
get_app_config()
config_path.unlink()
with pytest.raises(FileNotFoundError):
get_app_config()
loaded = peek_loaded_app_config()
assert loaded is not None
assert loaded.models[0].name == "model-a"
finally:
reset_app_config()
def test_get_app_config_reloads_when_content_digest_changes_without_metadata(tmp_path, monkeypatch):
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"

View File

@ -342,7 +342,7 @@ def test_runtime_api_version_matches_the_installed_contract_package():
"""Every additive contract slice bumps both gates together."""
from importlib.metadata import version
assert API_VERSION == "0.2.3"
assert API_VERSION == "0.2.4"
assert API_VERSION == version("deerflow-extension-api")

View File

@ -0,0 +1,394 @@
"""A registered plugin action is authorized before its handler runs (O2).
Drives the real route through a real FastAPI app and the real provider factory:
the target, the identity and the ordering (decision before the request body is
read) are all observable.
"""
from __future__ import annotations
import sys
from types import ModuleType, SimpleNamespace
import pytest
from deerflow_extension_api.auth import EXTENSION_PRINCIPAL_RESOLVER_KEY, ExtensionPrincipal
from deerflow_extension_api.plugins import BackendAction, PluginContribution
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway import authz as gateway_authz
from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL, AUTH_SOURCE_SESSION
from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE
from app.gateway.routers.plugins import router
from deerflow.authz.provider import AuthzDecision, AuthzReason
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
from deerflow.config.authorization_config import AuthorizationConfig, AuthorizationProviderConfig
from deerflow.config.model_config import ModelConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.extensions.registry import ExtensionRegistry
RBAC = "deerflow.authz.rbac:RbacAuthorizationProvider"
NAMESPACE = "community.check"
ACTION_URL = f"/api/plugins/{NAMESPACE}/actions/check"
_DECISIONS: list = []
class _RecordingProvider:
"""Records every decision and refuses the wrong entry points."""
name = "recording"
def __init__(self, *, allow: bool = True, fail: bool = False, reasons: object = None) -> None:
self._allow = allow
self._fail = fail
self._reasons = reasons
def authorize(self, request): # pragma: no cover - an async route must not use this
raise AssertionError("the async plugin path must not call authorize()")
async def aauthorize(self, request):
if self._fail:
raise RuntimeError("provider failed")
_DECISIONS.append(request)
reasons = self._reasons if self._reasons is not None else ([] if self._allow else [AuthzReason(code="authz.denied")])
return AuthzDecision(allow=self._allow, reasons=reasons)
def filter_resources(self, principal, resource_type, candidates): # pragma: no cover
raise AssertionError("the action path must not batch-filter")
class _ExplodingReasons:
"""An iterable that passes the decision layer's ``Iterable`` check, then raises while read."""
def __iter__(self):
yield "not-a-reason"
raise RuntimeError("reasons.__next__ failed")
@pytest.fixture(autouse=True)
def _isolated_authorization():
"""Both the config singleton and the plugin provider cache are process-global."""
reset_app_config()
gateway_authz._plugin_provider_cache.clear()
_DECISIONS.clear()
yield
gateway_authz._plugin_provider_cache.clear()
_DECISIONS.clear()
reset_app_config()
@pytest.fixture
def plugin_app():
calls = []
async def check(payload, context):
calls.append((payload, context))
return {"length": len(payload["text"])}
plugin = PluginContribution(
namespace=NAMESPACE,
title="Check",
enabled=True,
backend=(BackendAction("check", check),),
)
registry = ExtensionRegistry()
with registry.attributed_to("test:install"):
assert registry.plugin(plugin) is True
app = FastAPI()
app.state.extensions = registry.build()
identity = {"role": "user", "auth_source": AUTH_SOURCE_SESSION}
@app.middleware("http")
async def stamp_identity(request, call_next):
request.state.user = SimpleNamespace(id="user-1", system_role=identity["role"], oauth_provider=None, oauth_id=None)
request.state.auth_source = identity["auth_source"]
return await call_next(request)
setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, lambda request: ExtensionPrincipal("user-1"))
app.include_router(router)
with TestClient(app) as http:
yield http, calls, identity
def _use_authorization(*, roles: dict | None = None, enabled: bool = True, fail_closed: bool = True, provider_use: str = RBAC, provider_config: dict | None = None, default_role: str = "user") -> None:
authorization = AuthorizationConfig(
enabled=enabled,
fail_closed=fail_closed,
default_role=default_role,
provider=AuthorizationProviderConfig(use=provider_use, config=provider_config if provider_config is not None else ({"roles": roles or {}} if provider_use == RBAC else {})),
)
set_app_config(
AppConfig(
models=[ModelConfig(name="gpt-4", model="gpt-4", use="langchain_openai:ChatOpenAI")],
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"),
authorization=authorization,
)
)
def _recording_provider_class_path(monkeypatch: pytest.MonkeyPatch, name: str) -> str:
"""Install the recording provider under a class path the factory can import."""
module = ModuleType(name)
module.Provider = _RecordingProvider
monkeypatch.setitem(sys.modules, name, module)
return f"{name}:Provider"
# --- Allow and deny ------------------------------------------------------------
def test_allowed_action_reaches_the_handler_once(plugin_app, monkeypatch):
http, calls, _ = plugin_app
_use_authorization(provider_use=_recording_provider_class_path(monkeypatch, "plugin_action_allow_provider"))
response = http.post(ACTION_URL, json={"text": "hello"})
assert response.status_code == 200
assert response.json() == {"length": 5}
assert len(calls) == 1
(decision,) = _DECISIONS
assert (decision.resource, decision.action, decision.target) == ("plugin_action", "invoke", f"{NAMESPACE}/check")
assert decision.principal.user_id == "user-1"
assert decision.principal.role == "user"
def test_denied_action_returns_403_and_never_invokes_the_handler(plugin_app):
http, calls, _ = plugin_app
_use_authorization(roles={"user": {"plugin_actions": {"allow": []}}})
response = http.post(ACTION_URL, json={"text": "hello"})
assert response.status_code == 403
assert response.json() == {"detail": "Plugin action not permitted for your role."}
assert calls == []
def test_deny_is_decided_before_the_request_body_is_read(plugin_app):
"""A denied caller cannot spend the 256 KiB input budget."""
http, calls, _ = plugin_app
_use_authorization(roles={"user": {"plugin_actions": {"allow": []}}})
response = http.post(ACTION_URL, content=b"{" + b" " * (300 * 1024) + b"}")
assert response.status_code == 403
assert calls == []
def test_allow_list_matches_the_declared_target_exactly(plugin_app):
http, calls, _ = plugin_app
_use_authorization(roles={"user": {"plugin_actions": {"allow": [f"{NAMESPACE}/other"]}}})
assert http.post(ACTION_URL, json={"text": "hello"}).status_code == 403
assert calls == []
def test_no_policy_for_the_resource_is_unrestricted(plugin_app):
"""Omitted config key = unrestricted, the documented non-breaking path."""
http, calls, _ = plugin_app
_use_authorization(roles={"user": {"tools": {"allow": []}}})
assert http.post(ACTION_URL, json={"text": "hello"}).status_code == 200
assert len(calls) == 1
# --- Failure policy ------------------------------------------------------------
def test_provider_error_denies_under_fail_closed(plugin_app, monkeypatch):
http, calls, _ = plugin_app
class_path = _recording_provider_class_path(monkeypatch, "plugin_action_failing_provider")
_use_authorization(fail_closed=True, provider_use=class_path, provider_config={"fail": True})
assert http.post(ACTION_URL, json={"text": "hello"}).status_code == 403
assert calls == []
def test_provider_error_proceeds_under_fail_open(plugin_app, monkeypatch):
http, calls, _ = plugin_app
class_path = _recording_provider_class_path(monkeypatch, "plugin_action_failing_provider_open")
_use_authorization(fail_closed=False, provider_use=class_path, provider_config={"fail": True})
assert http.post(ACTION_URL, json={"text": "hello"}).status_code == 200
assert len(calls) == 1
def test_unresolvable_provider_denies_under_fail_closed(plugin_app):
http, calls, _ = plugin_app
_use_authorization(provider_use="nonexistent.module:FakeProvider")
assert http.post(ACTION_URL, json={"text": "hello"}).status_code == 403
assert calls == []
def test_unresolvable_provider_proceeds_under_fail_open(plugin_app):
http, calls, _ = plugin_app
_use_authorization(fail_closed=False, provider_use="nonexistent.module:FakeProvider")
assert http.post(ACTION_URL, json={"text": "hello"}).status_code == 200
assert len(calls) == 1
# --- Compatibility -------------------------------------------------------------
def test_disabled_authorization_is_a_noop(plugin_app):
"""No provider is resolved and today's behavior is exact."""
http, calls, _ = plugin_app
_use_authorization(enabled=False, provider_use="nonexistent.module:FakeProvider")
assert http.post(ACTION_URL, json={"text": "hello"}).status_code == 200
assert len(calls) == 1
assert _DECISIONS == []
def test_unknown_action_stays_a_404_without_a_decision(plugin_app):
http, calls, _ = plugin_app
_use_authorization(roles={"user": {"plugin_actions": {"allow": []}}})
assert http.post(f"/api/plugins/{NAMESPACE}/actions/missing", json={}).status_code == 404
assert calls == []
def test_unknown_namespace_stays_a_404(plugin_app):
http, calls, _ = plugin_app
_use_authorization(roles={"user": {"plugin_actions": {"allow": []}}})
assert http.post("/api/plugins/community.missing/actions/check", json={}).status_code == 404
assert calls == []
def test_disabled_plugin_is_refused_before_any_decision(plugin_app):
http, calls, _ = plugin_app
http.app.state.extensions = _extensions(enabled=False)
_use_authorization(provider_use="deerflow.authz.rbac:RbacAuthorizationProvider", provider_config={"roles": {"user": {"plugin_actions": {"allow": "*"}}}})
response = http.post(ACTION_URL, json={"text": "hello"})
assert response.status_code == 403
assert response.json() == {"detail": "Plugin disabled by administrator."}
assert calls == []
def test_internal_caller_falls_back_to_the_default_role(plugin_app, monkeypatch):
"""INTERNAL_SYSTEM_ROLE is not a policy role, so default_role applies."""
http, calls, identity = plugin_app
identity["role"] = INTERNAL_SYSTEM_ROLE
identity["auth_source"] = AUTH_SOURCE_INTERNAL
class_path = _recording_provider_class_path(monkeypatch, "plugin_action_internal_provider")
_use_authorization(default_role="guest", provider_use=class_path, provider_config={"allow": True})
assert http.post(ACTION_URL, json={"text": "hello"}).status_code == 200
(decision,) = _DECISIONS
assert decision.principal.role == "guest"
assert decision.principal.is_internal is True
def test_a_policy_change_is_observed_without_a_restart(plugin_app):
"""Each invocation asks the provider again; the cache is provider-level only."""
http, calls, _ = plugin_app
_use_authorization(roles={"user": {"plugin_actions": {"allow": []}}})
assert http.post(ACTION_URL, json={"text": "a"}).status_code == 403
_use_authorization(roles={"user": {"plugin_actions": {"allow": "*"}}})
assert http.post(ACTION_URL, json={"text": "a"}).status_code == 200
assert len(calls) == 1
def test_unreadable_config_denies_the_action(plugin_app, monkeypatch):
"""Review P1: a config that cannot be read mid-request is unavailable, not disabled."""
http, calls, _ = plugin_app
_use_authorization(roles={"user": {"plugin_actions": {"allow": "*"}}})
def broken_config():
raise RuntimeError("config is being rewritten")
monkeypatch.setattr("deerflow.config.app_config.get_app_config", broken_config)
monkeypatch.setattr("deerflow.config.get_app_config", broken_config)
response = http.post(ACTION_URL, json={"text": "hello"})
assert response.status_code == 403
assert response.json() == {"detail": "Plugin action not permitted for your role."}
assert calls == []
def test_no_config_at_all_keeps_todays_behavior(plugin_app):
"""A host with no configuration has no policy to apply, so the route is unchanged."""
http, calls, _ = plugin_app
assert http.post(ACTION_URL, json={"text": "hello"}).status_code == 200
assert len(calls) == 1
@pytest.mark.parametrize("fail_closed,expected", [(True, 403), (False, 200)])
def test_a_lost_config_follows_the_running_policy(plugin_app, monkeypatch, fail_closed: bool, expected: int):
"""Review P1: a Gateway that lost the config it started on must not look "disabled".
The Gateway publishes the configuration it started with; when a later read
fails, that policy decides — ``fail_closed`` (the default) denies instead of
treating an unreadable config as authorization never having been configured.
"""
def absent_config():
raise FileNotFoundError("`config.yaml` file not found")
_use_authorization(fail_closed=fail_closed, roles={"user": {"plugin_actions": {"allow": "*"}}})
monkeypatch.setattr("deerflow.config.app_config.get_app_config", absent_config)
monkeypatch.setattr("deerflow.config.get_app_config", absent_config)
http, calls, _ = plugin_app
response = http.post(ACTION_URL, json={"text": "hello"})
assert response.status_code == expected
assert len(calls) == (1 if expected == 200 else 0)
def test_malformed_allow_denies_under_fail_closed(plugin_app, monkeypatch):
"""Review P2: a truthy non-bool ``allow`` is a malformed decision, not a grant."""
http, calls, _ = plugin_app
class_path = _recording_provider_class_path(monkeypatch, "plugin_action_malformed_provider")
_use_authorization(fail_closed=True, provider_use=class_path, provider_config={"allow": "false"})
assert http.post(ACTION_URL, json={"text": "hello"}).status_code == 403
assert calls == []
def test_malformed_allow_follows_fail_open(plugin_app, monkeypatch):
http, calls, _ = plugin_app
class_path = _recording_provider_class_path(monkeypatch, "plugin_action_malformed_provider_open")
_use_authorization(fail_closed=False, provider_use=class_path, provider_config={"allow": "false"})
assert http.post(ACTION_URL, json={"text": "hello"}).status_code == 200
assert len(calls) == 1
@pytest.mark.parametrize("fail_closed", [True, False])
def test_unreadable_reasons_deny_instead_of_erroring(plugin_app, monkeypatch, fail_closed: bool):
"""Review P3: a denial whose ``reasons`` raise while being read is still a denial.
The verdict is a valid deny; only the informative ``reason_code`` cannot be
read. Extracting it must not let the error escape as an unhandled 500, and
must not let ``fail_closed: false`` turn the explicit deny into an allow.
"""
http, calls, _ = plugin_app
class_path = _recording_provider_class_path(monkeypatch, "plugin_action_exploding_reasons_provider")
_use_authorization(fail_closed=fail_closed, provider_use=class_path, provider_config={"allow": False, "reasons": _ExplodingReasons()})
response = http.post(ACTION_URL, json={"text": "hello"})
assert response.status_code == 403
assert response.json() == {"detail": "Plugin action not permitted for your role."}
assert calls == []
def _extensions(*, enabled: bool) -> object:
async def check(payload, context): # pragma: no cover - never invoked
return {}
plugin = PluginContribution(namespace=NAMESPACE, title="Check", enabled=enabled, backend=(BackendAction("check", check),))
registry = ExtensionRegistry()
with registry.attributed_to("test:install"):
registry.plugin(plugin)
return registry.build()

View File

@ -0,0 +1,894 @@
"""Plugin management authority: the public guard, the host resolvers, and the provider cache.
Covers, in order:
1. the fail-closed contract of ``require_plugin_management`` / ``arequire_plugin_management``;
2. the harness decision functions those helpers call;
3. the resolvers the Gateway installs on ``app.state``;
4. the loop-keyed plugin provider cache (never a cross-loop or cross-sync reuse).
"""
from __future__ import annotations
import asyncio
import sys
from types import ModuleType, SimpleNamespace
import pytest
from deerflow_extension_api import (
EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY,
EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY,
EXTENSION_PRINCIPAL_RESOLVER_KEY,
ExtensionPrincipal,
arequire_plugin_management,
require_plugin_management,
)
from deerflow_extension_api.plugins import BackendAction, PluginContribution
from app.gateway import authz as gateway_authz
from app.gateway.auth_disabled import AUTH_SOURCE_SESSION
from deerflow.authz.plugin_authz import (
PluginAuthorizationError,
aenforce_plugin_action,
aenforce_plugin_management,
afilter_plugin_management,
afilter_plugin_pages,
enforce_plugin_action,
enforce_plugin_management,
)
from deerflow.authz.plugin_targets import (
MANAGEMENT_READ_PART,
MANAGEMENT_WRITE_PART,
plugin_management_target,
)
from deerflow.authz.provider import AuthzDecision, AuthzReason, AuthzRequest, Principal
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
from deerflow.config.authorization_config import AuthorizationConfig, AuthorizationProviderConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.extensions.registry import ExtensionRegistry
RBAC = "deerflow.authz.rbac:RbacAuthorizationProvider"
NAMESPACE = "community.check"
READ_TARGET = plugin_management_target(NAMESPACE, MANAGEMENT_READ_PART)
WRITE_TARGET = plugin_management_target(NAMESPACE, MANAGEMENT_WRITE_PART)
# --- Shared helpers ------------------------------------------------------------
class _RecordingProvider:
"""Records which entry point a caller used, so placement is observable."""
name = "recording"
def __init__(self, *, allow: bool = True, fail: str | None = None, decision: object = None) -> None:
self.sync_requests: list[AuthzRequest] = []
self.async_requests: list[AuthzRequest] = []
self.filters: list[tuple[str, list[str]]] = []
self._allow = allow
self._fail = fail
self._decision = decision
def _decide(self) -> object:
if self._fail == "authorize":
raise RuntimeError("provider failed")
if self._decision is not None:
return self._decision
return AuthzDecision(allow=self._allow, reasons=[] if self._allow else [AuthzReason(code="authz.denied")])
def authorize(self, request: AuthzRequest):
self.sync_requests.append(request)
return self._decide()
async def aauthorize(self, request: AuthzRequest):
self.async_requests.append(request)
return self._decide()
def filter_resources(self, principal: Principal, resource_type: str, candidates: list[str]):
if self._fail == "filter":
raise RuntimeError("provider failed")
self.filters.append((resource_type, list(candidates)))
return [candidate for candidate in candidates if self._allow]
class _ExplodingReasons:
"""An iterable that satisfies ``isinstance(reasons, Iterable)`` and raises while consumed.
Two ``__iter__`` shapes: raising outright, or yielding a non-``AuthzReason``
and then raising from ``__next__``. Both are reusable — every ``__iter__``
call builds a fresh generator.
"""
def __init__(self, *, raises_while_iterating: bool = False) -> None:
self._raises_while_iterating = raises_while_iterating
def __iter__(self):
if not self._raises_while_iterating:
raise RuntimeError("reasons.__iter__ failed")
return self._explode()
def _explode(self):
yield "not-a-reason"
raise RuntimeError("reasons.__next__ failed")
def _authorization_config(*, enabled: bool = True, fail_closed: bool = True, roles: dict | None = None, provider_use: str = RBAC, provider_config: dict | None = None, default_role: str = "user") -> AuthorizationConfig:
if provider_config is None:
provider_config = {"roles": roles or {}} if provider_use == RBAC else {}
return AuthorizationConfig(
enabled=enabled,
fail_closed=fail_closed,
default_role=default_role,
provider=AuthorizationProviderConfig(use=provider_use, config=provider_config),
)
def _app_config(**kwargs) -> AppConfig:
return AppConfig(sandbox=SandboxConfig(use="test"), authorization=_authorization_config(**kwargs))
def _principal(role: str = "user") -> Principal:
return Principal(user_id="user-1", role=role)
def _use_provider(monkeypatch: pytest.MonkeyPatch, provider: object) -> None:
"""Serve *provider* from every resolution path the decision layer can take."""
import deerflow.authz.plugin_authz as plugin_authz
monkeypatch.setattr(plugin_authz, "resolve_authorization_provider", lambda config: provider)
monkeypatch.setattr(plugin_authz, "resolve_authorization_provider_spec", lambda config: object())
monkeypatch.setattr(plugin_authz, "construct_authorization_provider", lambda spec, config: provider)
_DEFAULT_USER = object()
def _plain_request(*, user: object = _DEFAULT_USER, auth_source: str = AUTH_SOURCE_SESSION, app: object | None = None) -> SimpleNamespace:
"""A request-like object; pass ``user=None`` for an anonymous caller."""
if user is _DEFAULT_USER:
user = SimpleNamespace(id="user-1", system_role="user", oauth_provider=None, oauth_id=None)
return SimpleNamespace(app=app, state=SimpleNamespace(user=user, auth_source=auth_source), headers={})
# --- 1. The public guard -------------------------------------------------------
_GUARD_PRINCIPAL = ExtensionPrincipal("u1")
def _guard_request(*, principal: ExtensionPrincipal | None = _GUARD_PRINCIPAL, **state: object) -> SimpleNamespace:
attributes: dict[str, object] = {EXTENSION_PRINCIPAL_RESOLVER_KEY: (lambda request: principal)}
attributes.update(state)
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(**attributes)))
def test_guard_returns_the_principal_when_the_host_allows():
request = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY: lambda request, namespace, scope: True})
assert require_plugin_management(request, NAMESPACE, scope="write") == _GUARD_PRINCIPAL
@pytest.mark.parametrize("answer", [False, None, "yes", 1, 0])
def test_guard_rejects_every_non_true_answer(answer):
request = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY: lambda request, namespace, scope: answer})
with pytest.raises(PermissionError):
require_plugin_management(request, NAMESPACE)
def test_guard_fails_closed_without_a_resolver():
with pytest.raises(PermissionError):
require_plugin_management(_guard_request(), NAMESPACE)
def test_guard_fails_closed_when_the_resolver_fails():
def boom(request, namespace, scope):
raise RuntimeError("nope")
request = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY: boom})
with pytest.raises(PermissionError):
require_plugin_management(request, NAMESPACE)
def test_guard_fails_closed_without_an_authenticated_principal():
"""Identity is asked before authority: no identity is not an answer."""
request = _guard_request(principal=None, **{EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY: lambda request, namespace, scope: True})
with pytest.raises(PermissionError):
require_plugin_management(request, NAMESPACE)
@pytest.mark.parametrize("scope", ["admin", "", "READ", None])
def test_guard_rejects_an_unknown_scope(scope):
request = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY: lambda request, namespace, scope: True})
with pytest.raises(ValueError):
require_plugin_management(request, NAMESPACE, scope=scope)
@pytest.mark.parametrize("namespace", ["", "Bad", "a/b", 1, None])
def test_guard_rejects_a_malformed_namespace(namespace):
request = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY: lambda request, namespace, scope: True})
with pytest.raises(ValueError):
require_plugin_management(request, namespace)
def test_guard_asks_the_resolver_with_the_caller_namespace_and_scope():
seen = []
def resolver(request, namespace, scope):
seen.append((namespace, scope))
return True
require_plugin_management(_guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY: resolver}), NAMESPACE, scope="write")
assert seen == [(NAMESPACE, "write")]
@pytest.mark.asyncio
async def test_async_guard_allows_and_denies():
async def allow(request, namespace, scope):
return True
async def deny(request, namespace, scope):
return False
allowed = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY: allow})
assert await arequire_plugin_management(allowed, NAMESPACE) == _GUARD_PRINCIPAL
denied = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY: deny})
with pytest.raises(PermissionError):
await arequire_plugin_management(denied, NAMESPACE)
@pytest.mark.asyncio
async def test_async_guard_never_falls_back_to_the_sync_resolver():
"""Running the sync answer here would put the host's config read on the loop."""
sync_only = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY: lambda request, namespace, scope: True})
with pytest.raises(PermissionError):
await arequire_plugin_management(sync_only, NAMESPACE)
async_only = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY: lambda request, namespace, scope: True})
with pytest.raises(PermissionError):
require_plugin_management(async_only, NAMESPACE)
@pytest.mark.asyncio
async def test_async_guard_fails_closed_on_a_sync_resolver_and_on_errors():
def wrong_shape(request, namespace, scope):
return True
async def boom(request, namespace, scope):
raise RuntimeError("nope")
for resolver in (wrong_shape, boom):
request = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY: resolver})
with pytest.raises(PermissionError):
await arequire_plugin_management(request, NAMESPACE)
# --- 2. The decision layer -----------------------------------------------------
def test_action_decision_uses_the_composed_target(monkeypatch):
provider = _RecordingProvider()
_use_provider(monkeypatch, provider)
enforce_plugin_action(principal=_principal(), app_config=_app_config(), namespace=NAMESPACE, action_name="check")
(request,) = provider.sync_requests
assert (request.resource, request.action, request.target) == ("plugin_action", "invoke", f"{NAMESPACE}/check")
def test_management_read_and_write_are_distinct_targets(monkeypatch):
provider = _RecordingProvider()
_use_provider(monkeypatch, provider)
enforce_plugin_management(principal=_principal(), app_config=_app_config(), namespace=NAMESPACE, write=False)
enforce_plugin_management(principal=_principal(), app_config=_app_config(), namespace=NAMESPACE, write=True)
assert [(request.resource, request.action, request.target) for request in provider.sync_requests] == [
("plugin_management", "read", READ_TARGET),
("plugin_management", "write", WRITE_TARGET),
]
def test_built_in_provider_separates_read_from_write_authority():
"""One resource, two targets, two verdicts — the built-in provider ignores ``action``."""
config = _app_config(roles={"user": {"plugin_management": {"allow": [READ_TARGET]}}})
enforce_plugin_management(principal=_principal(), app_config=config, namespace=NAMESPACE, write=False)
with pytest.raises(PluginAuthorizationError) as error:
enforce_plugin_management(principal=_principal(), app_config=config, namespace=NAMESPACE, write=True)
assert (error.value.resource, error.value.target) == ("plugin_management", WRITE_TARGET)
assert error.value.fail_closed is True
def test_deny_reports_the_provider_reason(monkeypatch):
provider = _RecordingProvider(allow=False)
_use_provider(monkeypatch, provider)
with pytest.raises(PluginAuthorizationError) as error:
enforce_plugin_action(principal=_principal(), app_config=_app_config(), namespace=NAMESPACE, action_name="check")
assert error.value.reason_code == "authz.denied"
assert error.value.resource == "plugin_action"
assert error.value.target == f"{NAMESPACE}/check"
def test_disabled_authorization_never_asks_the_provider(monkeypatch):
provider = _RecordingProvider()
_use_provider(monkeypatch, provider)
config = _app_config(enabled=False)
enforce_plugin_action(principal=_principal(), app_config=config, namespace=NAMESPACE, action_name="check")
enforce_plugin_management(principal=_principal(), app_config=config, namespace=NAMESPACE, write=True)
assert provider.sync_requests == []
@pytest.mark.parametrize("fail_closed", [True, False])
@pytest.mark.parametrize("provider_kwargs", [{"fail": "authorize"}, {"decision": "not-a-decision"}])
def test_unanswerable_decisions_follow_fail_closed(monkeypatch, fail_closed: bool, provider_kwargs: dict):
provider = _RecordingProvider(**provider_kwargs)
_use_provider(monkeypatch, provider)
config = _app_config(fail_closed=fail_closed)
if fail_closed:
with pytest.raises(PluginAuthorizationError) as error:
enforce_plugin_action(principal=_principal(), app_config=config, namespace=NAMESPACE, action_name="check")
assert error.value.reason_code == "authz.provider_error"
else:
enforce_plugin_action(principal=_principal(), app_config=config, namespace=NAMESPACE, action_name="check")
@pytest.mark.parametrize("fail_closed", [True, False])
def test_a_missing_principal_follows_fail_closed(monkeypatch, fail_closed: bool):
provider = _RecordingProvider()
_use_provider(monkeypatch, provider)
if fail_closed:
with pytest.raises(PluginAuthorizationError) as error:
enforce_plugin_management(principal=None, app_config=_app_config(), namespace=NAMESPACE, write=False)
assert error.value.reason_code == "authz.no_principal"
else:
enforce_plugin_management(principal=None, app_config=_app_config(fail_closed=False), namespace=NAMESPACE, write=False)
assert provider.sync_requests == []
@pytest.mark.asyncio
async def test_async_decisions_await_the_async_provider(monkeypatch):
provider = _RecordingProvider()
_use_provider(monkeypatch, provider)
config = _app_config()
await aenforce_plugin_action(principal=_principal(), app_config=config, namespace=NAMESPACE, action_name="check")
await aenforce_plugin_management(principal=_principal(), app_config=config, namespace=NAMESPACE, write=True)
assert provider.sync_requests == []
assert [(request.resource, request.target) for request in provider.async_requests] == [
("plugin_action", f"{NAMESPACE}/check"),
("plugin_management", WRITE_TARGET),
]
@pytest.mark.asyncio
async def test_async_decision_honours_fail_closed(monkeypatch):
_use_provider(monkeypatch, _RecordingProvider(fail="authorize"))
with pytest.raises(PluginAuthorizationError):
await aenforce_plugin_action(principal=_principal(), app_config=_app_config(), namespace=NAMESPACE, action_name="check")
await aenforce_plugin_action(principal=_principal(), app_config=_app_config(fail_closed=False), namespace=NAMESPACE, action_name="check")
def test_an_unreadable_config_denies_instead_of_allowing(monkeypatch):
"""Review P1: config *unavailable* is not the same as a disabled policy.
The flag that would permit an allow cannot be read, so the check fails closed
rather than silently passing the request.
"""
_use_provider(monkeypatch, _RecordingProvider())
with pytest.raises(PluginAuthorizationError) as error:
enforce_plugin_action(principal=_principal(), app_config=None, namespace=NAMESPACE, action_name="check")
assert error.value.reason_code == "authz.config_unavailable"
assert error.value.fail_closed is True
@pytest.mark.asyncio
async def test_an_unreadable_config_denies_the_async_paths(monkeypatch):
_use_provider(monkeypatch, _RecordingProvider())
with pytest.raises(PluginAuthorizationError) as error:
await aenforce_plugin_management(principal=_principal(), app_config=None, namespace=NAMESPACE, write=True)
assert error.value.reason_code == "authz.config_unavailable"
with pytest.raises(PluginAuthorizationError):
await afilter_plugin_management(principal=_principal(), app_config=None, candidates=[READ_TARGET])
@pytest.mark.parametrize("allow", ["false", "true", 1, 0, [], None])
@pytest.mark.parametrize("fail_closed", [True, False])
def test_a_non_bool_allow_is_a_malformed_decision(monkeypatch, allow, fail_closed):
"""Review P2: ``AuthzDecision(allow="false")`` is truthy and must not be an allow."""
_use_provider(monkeypatch, _RecordingProvider(decision=AuthzDecision(allow=allow)))
config = _app_config(fail_closed=fail_closed)
if fail_closed:
with pytest.raises(PluginAuthorizationError) as error:
enforce_plugin_management(principal=_principal(), app_config=config, namespace=NAMESPACE, write=True)
assert error.value.reason_code == "authz.provider_error"
else:
enforce_plugin_management(principal=_principal(), app_config=config, namespace=NAMESPACE, write=True)
@pytest.mark.parametrize("reasons", [None, 5, _ExplodingReasons(), _ExplodingReasons(raises_while_iterating=True)])
@pytest.mark.parametrize("fail_closed", [True, False])
def test_malformed_reasons_keep_the_denial(monkeypatch, reasons, fail_closed):
"""Review P2/P3: a denied verdict with unusable ``reasons`` still denies.
``AuthzDecision`` is a plain dataclass, so ``reasons`` can be any object —
including one that passes the ``Iterable`` check and then raises from
``__iter__``/``__next__``. The verdict is a valid denial (``allow`` is a
real ``bool``), so the denial stands and only the informative code degrades:
extracting it must not raise, or a denial would escape as an unhandled error
instead of ``403``.
"""
_use_provider(monkeypatch, _RecordingProvider(decision=AuthzDecision(allow=False, reasons=reasons)))
with pytest.raises(PluginAuthorizationError) as error:
enforce_plugin_management(principal=_principal(), app_config=_app_config(fail_closed=fail_closed), namespace=NAMESPACE, write=True)
assert error.value.reason_code == "authz.denied"
assert error.value.fail_closed is fail_closed
@pytest.mark.asyncio
@pytest.mark.parametrize("reasons", [None, 5, _ExplodingReasons(), _ExplodingReasons(raises_while_iterating=True)])
async def test_async_malformed_reasons_keep_the_denial(monkeypatch, reasons):
_use_provider(monkeypatch, _RecordingProvider(decision=AuthzDecision(allow=False, reasons=reasons)))
with pytest.raises(PluginAuthorizationError) as error:
await aenforce_plugin_action(principal=_principal(), app_config=_app_config(), namespace=NAMESPACE, action_name="check")
assert error.value.reason_code == "authz.denied"
@pytest.mark.asyncio
@pytest.mark.parametrize("allow", ["false", 1])
@pytest.mark.parametrize("fail_closed", [True, False])
async def test_async_non_bool_allow_is_a_malformed_decision(monkeypatch, allow, fail_closed):
_use_provider(monkeypatch, _RecordingProvider(decision=AuthzDecision(allow=allow)))
config = _app_config(fail_closed=fail_closed)
if fail_closed:
with pytest.raises(PluginAuthorizationError) as error:
await aenforce_plugin_action(principal=_principal(), app_config=config, namespace=NAMESPACE, action_name="check")
assert error.value.reason_code == "authz.provider_error"
else:
await aenforce_plugin_action(principal=_principal(), app_config=config, namespace=NAMESPACE, action_name="check")
@pytest.mark.asyncio
async def test_batch_filters_use_the_batch_entry_point(monkeypatch):
provider = _RecordingProvider()
_use_provider(monkeypatch, provider)
config = _app_config()
allowed = await afilter_plugin_management(principal=_principal(), app_config=config, candidates=[READ_TARGET, WRITE_TARGET])
pages = await afilter_plugin_pages(principal=_principal(), app_config=config, candidates=[f"{NAMESPACE}/reports"])
assert allowed == frozenset({READ_TARGET, WRITE_TARGET})
assert pages == frozenset({f"{NAMESPACE}/reports"})
assert provider.filters == [
("plugin_management", [READ_TARGET, WRITE_TARGET]),
("plugin_page", [f"{NAMESPACE}/reports"]),
]
@pytest.mark.asyncio
async def test_batch_filter_never_returns_a_target_it_was_not_asked_about(monkeypatch):
"""A provider answer can only narrow the candidates, never widen them.
``filter_resources`` is a plain method on a custom provider, so an answer
naming an unrelated target (``"*"``, another namespace) must not reach the
projection as visible authority.
"""
class _OversharingProvider(_RecordingProvider):
def filter_resources(self, principal, resource_type, candidates):
return [*candidates, "*", f"{NAMESPACE}/injected"]
_use_provider(monkeypatch, _OversharingProvider())
pages = await afilter_plugin_pages(principal=_principal(), app_config=_app_config(), candidates=[f"{NAMESPACE}/reports"])
management = await afilter_plugin_management(principal=_principal(), app_config=_app_config(), candidates=[READ_TARGET])
assert pages == frozenset({f"{NAMESPACE}/reports"})
assert management == frozenset({READ_TARGET})
@pytest.mark.asyncio
async def test_batch_filter_denies_every_candidate(monkeypatch):
_use_provider(monkeypatch, _RecordingProvider(allow=False))
allowed = await afilter_plugin_management(principal=_principal(), app_config=_app_config(), candidates=[READ_TARGET, WRITE_TARGET])
assert allowed == frozenset()
@pytest.mark.asyncio
async def test_batch_filters_skip_the_provider_without_candidates(monkeypatch):
"""No candidate means no provider round trip."""
provider = _RecordingProvider()
_use_provider(monkeypatch, provider)
assert await afilter_plugin_pages(principal=_principal(), app_config=_app_config(), candidates=[]) == frozenset()
assert provider.filters == []
@pytest.mark.asyncio
async def test_batch_filters_are_unrestricted_while_authorization_is_disabled(monkeypatch):
provider = _RecordingProvider(allow=False)
_use_provider(monkeypatch, provider)
config = _app_config(enabled=False)
allowed = await afilter_plugin_management(principal=_principal(), app_config=config, candidates=[READ_TARGET])
assert allowed == frozenset({READ_TARGET})
assert provider.filters == []
@pytest.mark.asyncio
@pytest.mark.parametrize("fail_closed", [True, False])
async def test_batch_filter_failure_follows_fail_closed(monkeypatch, fail_closed: bool):
_use_provider(monkeypatch, _RecordingProvider(fail="filter"))
config = _app_config(fail_closed=fail_closed)
if fail_closed:
with pytest.raises(PluginAuthorizationError) as error:
await afilter_plugin_management(principal=_principal(), app_config=config, candidates=[READ_TARGET])
assert error.value.reason_code == "authz.provider_error"
else:
assert await afilter_plugin_management(principal=_principal(), app_config=config, candidates=[READ_TARGET]) == frozenset({READ_TARGET})
# --- 3. The host-installed resolvers -------------------------------------------
def _plugin_extensions(*, namespace: str = NAMESPACE) -> object:
async def check(payload, context): # pragma: no cover - never invoked
return {}
plugin = PluginContribution(namespace=namespace, title="Check", enabled=True, backend=(BackendAction("check", check),))
registry = ExtensionRegistry()
with registry.attributed_to("test:install"):
registry.plugin(plugin)
return registry.build()
@pytest.fixture
def host_app(monkeypatch: pytest.MonkeyPatch):
"""A real ``create_app()`` with a stub config and our own plugin snapshot."""
import app.gateway.app as app_module
from deerflow.extensions import reset_loaded_extensions, reset_runtime_diagnostics
reset_app_config()
gateway_authz._plugin_provider_cache.clear()
monkeypatch.setattr(app_module, "get_app_config", lambda: AppConfig(sandbox=SandboxConfig(use="test")))
reset_loaded_extensions()
reset_runtime_diagnostics()
from app.gateway.app import create_app
app = create_app()
app.state.extensions = _plugin_extensions()
try:
yield app
finally:
gateway_authz._plugin_provider_cache.clear()
reset_app_config()
reset_loaded_extensions()
reset_runtime_diagnostics()
def test_gateway_installs_both_plugin_management_resolvers(host_app):
assert callable(getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY, None))
assert callable(getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY, None))
def test_installed_resolver_is_a_noop_while_authorization_is_disabled(host_app):
set_app_config(_app_config(enabled=False))
resolver = getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY)
request = _plain_request(app=host_app)
assert resolver(request, NAMESPACE, "read") is True
assert resolver(request, NAMESPACE, "write") is True
# And the public helper therefore allows an enterprise route.
assert require_plugin_management(request, NAMESPACE, scope="write").user_id == "user-1"
def test_installed_resolvers_decide_read_and_write_independently(host_app):
set_app_config(_app_config(roles={"user": {"plugin_management": {"allow": [READ_TARGET]}}}))
request = _plain_request(app=host_app)
assert require_plugin_management(request, NAMESPACE, scope="read").user_id == "user-1"
with pytest.raises(PermissionError):
require_plugin_management(request, NAMESPACE, scope="write")
def test_installed_resolvers_cannot_answer_an_unknown_plugin(host_app):
set_app_config(_app_config(roles={"user": {"plugin_management": {"allow": "*"}}}))
request = _plain_request(app=host_app)
resolver = getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY)
assert resolver(request, "community.missing", "read") is None
with pytest.raises(PermissionError):
require_plugin_management(request, "community.missing")
def test_installed_resolvers_cannot_answer_an_anonymous_caller(host_app):
set_app_config(_app_config(roles={"user": {"plugin_management": {"allow": "*"}}}))
resolver = getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY)
assert resolver(_plain_request(user=None, app=host_app), NAMESPACE, "read") is None
@pytest.mark.asyncio
async def test_installed_async_resolver_matches_the_sync_answer(host_app):
set_app_config(_app_config(roles={"user": {"plugin_management": {"allow": [READ_TARGET]}}}))
request = _plain_request(app=host_app)
assert (await arequire_plugin_management(request, NAMESPACE, scope="read")).user_id == "user-1"
with pytest.raises(PermissionError):
await arequire_plugin_management(request, NAMESPACE, scope="write")
def test_installed_resolver_denies_when_the_config_cannot_be_read(host_app, monkeypatch):
"""Review P1: a config read failure must not resolve to 'allowed'."""
set_app_config(_app_config(roles={"user": {"plugin_management": {"allow": "*"}}}))
def broken_config():
raise RuntimeError("config is being rewritten")
monkeypatch.setattr("deerflow.config.app_config.get_app_config", broken_config)
monkeypatch.setattr("deerflow.config.get_app_config", broken_config)
resolver = getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY)
assert resolver(_plain_request(app=host_app), NAMESPACE, "read") is False
def test_installed_resolver_allows_when_no_config_exists(host_app, monkeypatch):
"""A host that never had a config has no policy to apply: no gate.
Same rule the route-scoped gates use (``_get_route_authorization_config``,
``deerflow.authz.sandbox_authz.safe_app_config``): authorization can only be
enabled through config, so a ``config.yaml``-less host (CI runner, direct
call, a host that mounts only the plugins router) keeps today's behavior.
"""
def absent_config():
raise FileNotFoundError("`config.yaml` file not found")
monkeypatch.setattr("deerflow.config.app_config.get_app_config", absent_config)
monkeypatch.setattr("deerflow.config.get_app_config", absent_config)
resolver = getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY)
assert resolver(_plain_request(app=host_app), NAMESPACE, "read") is True
@pytest.mark.parametrize("fail_closed,expected", [(True, False), (False, True)])
def test_installed_resolver_applies_the_running_policy_when_the_config_disappears(host_app, monkeypatch, fail_closed: bool, expected: bool):
"""Review P1: a host that lost the config it is running on must not answer 'allowed'.
The configuration this process loaded decides the failure: an enabled policy
follows its own ``fail_closed`` (``True`` by default) instead of the read
failure being read as "authorization was never configured".
"""
set_app_config(_app_config(fail_closed=fail_closed, roles={"user": {"plugin_management": {"allow": "*"}}}))
def absent_config():
raise FileNotFoundError("`config.yaml` file not found")
monkeypatch.setattr("deerflow.config.app_config.get_app_config", absent_config)
monkeypatch.setattr("deerflow.config.get_app_config", absent_config)
resolver = getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY)
assert resolver(_plain_request(app=host_app), NAMESPACE, "read") is expected
@pytest.mark.asyncio
async def test_installed_async_resolver_denies_when_the_config_disappears(host_app, monkeypatch):
"""The async resolver reaches the same answer through ``aresolve_plugin_authorization``."""
set_app_config(_app_config(roles={"user": {"plugin_management": {"allow": "*"}}}))
def absent_config():
raise FileNotFoundError("`config.yaml` file not found")
monkeypatch.setattr("deerflow.config.app_config.get_app_config", absent_config)
monkeypatch.setattr("deerflow.config.get_app_config", absent_config)
with pytest.raises(PermissionError):
await arequire_plugin_management(_plain_request(app=host_app), NAMESPACE, scope="read")
def test_installed_resolver_stays_noop_when_the_lost_config_had_authorization_off(host_app, monkeypatch):
"""The running policy decides: a loaded config with authorization off has no gate to apply."""
set_app_config(_app_config(enabled=False))
def absent_config():
raise FileNotFoundError("`config.yaml` file not found")
monkeypatch.setattr("deerflow.config.app_config.get_app_config", absent_config)
monkeypatch.setattr("deerflow.config.get_app_config", absent_config)
resolver = getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY)
assert resolver(_plain_request(app=host_app), NAMESPACE, "read") is True
@pytest.mark.parametrize("fail_closed,expected", [(True, False), (False, True)])
def test_installed_resolver_applies_the_failure_policy(host_app, fail_closed: bool, expected: bool):
set_app_config(_app_config(fail_closed=fail_closed, provider_use="nonexistent.module:FakeProvider"))
resolver = getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY)
assert resolver(_plain_request(app=host_app), NAMESPACE, "read") is expected
# --- 4. Provider lifecycle -----------------------------------------------------
def _loop_affine_module(monkeypatch: pytest.MonkeyPatch) -> tuple[str, list]:
"""A provider whose construction is only valid on a running event loop."""
module = ModuleType("plugin_provider_lifecycle_test_module")
constructed: list = []
class LoopAffineProvider:
name = "loop-affine"
def __init__(self, *, tolerate_no_loop: bool = False) -> None:
try:
self.loop = asyncio.get_running_loop()
except RuntimeError:
if not tolerate_no_loop:
raise
self.loop = None
constructed.append(self.loop)
def authorize(self, request):
return AuthzDecision(allow=True)
async def aauthorize(self, request):
return AuthzDecision(allow=True)
def filter_resources(self, principal, resource_type, candidates):
return list(candidates)
module.LoopAffineProvider = LoopAffineProvider
monkeypatch.setitem(sys.modules, module.__name__, module)
return f"{module.__name__}:LoopAffineProvider", constructed
@pytest.fixture(autouse=True)
def _isolated_provider_cache():
gateway_authz._plugin_provider_cache.clear()
reset_app_config()
yield
gateway_authz._plugin_provider_cache.clear()
reset_app_config()
@pytest.mark.asyncio
async def test_async_resolution_constructs_on_the_calling_loop(monkeypatch):
class_path, constructed = _loop_affine_module(monkeypatch)
set_app_config(_app_config(provider_use=class_path))
provider, principal, app_config = await gateway_authz.aresolve_plugin_authorization(_plain_request())
running_loop = asyncio.get_running_loop()
assert provider is not None and provider.loop is running_loop
assert constructed == [running_loop]
assert principal is not None and principal.user_id == "user-1"
assert app_config is not None and app_config.authorization.enabled is True
@pytest.mark.asyncio
async def test_a_hot_path_neither_rediscovers_nor_reconstructs(monkeypatch):
class_path, constructed = _loop_affine_module(monkeypatch)
set_app_config(_app_config(provider_use=class_path))
request = _plain_request()
first, _, _ = await gateway_authz.aresolve_plugin_authorization(request)
discoveries: list = []
monkeypatch.setattr(gateway_authz, "resolve_authorization_provider_spec", lambda config: discoveries.append(1))
second, _, _ = await gateway_authz.aresolve_plugin_authorization(request)
assert second is first
assert constructed == [asyncio.get_running_loop()]
assert discoveries == []
@pytest.mark.asyncio
async def test_a_configuration_change_rebuilds_the_provider(monkeypatch):
class_path, constructed = _loop_affine_module(monkeypatch)
set_app_config(_app_config(provider_use=class_path))
first, _, _ = await gateway_authz.aresolve_plugin_authorization(_plain_request())
set_app_config(_app_config(provider_use=class_path, default_role="guest"))
second, _, _ = await gateway_authz.aresolve_plugin_authorization(_plain_request())
assert second is not first
assert len(constructed) == 2
def test_the_sync_slot_never_reuses_a_loop_provider(monkeypatch):
class_path, constructed = _loop_affine_module(monkeypatch)
config = _app_config(provider_use=class_path, provider_config={"tolerate_no_loop": True})
set_app_config(config)
async def _async_resolve():
return (await gateway_authz.aresolve_plugin_authorization(_plain_request()))[0]
loop_provider = asyncio.run(_async_resolve())
sync_provider, principal, sync_config = gateway_authz.resolve_plugin_authorization(_plain_request())
assert loop_provider is not None and loop_provider.loop is not None
assert sync_provider is not None
assert sync_provider is not loop_provider
assert sync_provider.loop is None
assert principal is not None
# The sync caller keeps its own slot on the next call.
assert gateway_authz.resolve_plugin_authorization(_plain_request())[0] is sync_provider
assert len(constructed) == 2
def test_each_event_loop_gets_its_own_provider(monkeypatch):
class_path, constructed = _loop_affine_module(monkeypatch)
set_app_config(_app_config(provider_use=class_path, provider_config={"tolerate_no_loop": True}))
async def _async_resolve():
return (await gateway_authz.aresolve_plugin_authorization(_plain_request()))[0]
first = asyncio.run(_async_resolve())
second = asyncio.run(_async_resolve())
assert first is not second
assert first.loop is not second.loop
assert len(constructed) == 2
@pytest.mark.asyncio
async def test_concurrent_cold_requests_still_decide_correctly(monkeypatch):
"""No single-flight lock: a duplicate construction is accepted, not a failure."""
class_path, constructed = _loop_affine_module(monkeypatch)
set_app_config(_app_config(provider_use=class_path))
first, second = await asyncio.gather(
gateway_authz.aresolve_plugin_authorization(_plain_request()),
gateway_authz.aresolve_plugin_authorization(_plain_request()),
)
assert len(constructed) == 2
for provider, principal, _snapshot in (first, second):
assert provider is not None and principal is not None
decision = await provider.aauthorize(AuthzRequest(principal=principal, resource="plugin_action", action="invoke", target=f"{NAMESPACE}/check"))
assert decision.allow is True
def test_anonymous_callers_get_a_provider_without_a_principal(monkeypatch):
class_path, _ = _loop_affine_module(monkeypatch)
set_app_config(_app_config(provider_use=class_path, provider_config={"tolerate_no_loop": True}))
provider, principal, app_config = gateway_authz.resolve_plugin_authorization(_plain_request(user=None))
assert provider is not None
assert principal is None

View File

@ -0,0 +1,185 @@
"""Composite plugin authorization targets: one encoder, one validator.
The three constructors are the only sanctioned producers of the strings a
plugin decision is keyed on, so these tests pin the charsets, the cross-kind
shape, and the drift guard that keeps the Python copies of the host's
namespace rule (authz, extension-api, plugin settings) from diverging.
"""
from __future__ import annotations
import pytest
from deerflow.authz.plugin_targets import (
MANAGEMENT_READ_PART,
MANAGEMENT_WRITE_PART,
PluginTargetError,
plugin_action_target,
plugin_management_target,
plugin_page_target,
)
# One sample set reused for every copy of the namespace rule, so a divergence
# between them fails here instead of at runtime.
VALID_NAMESPACES = [
"a",
"acme.reports",
"community.check",
"a1.b-c_d",
"a" * 96,
]
INVALID_NAMESPACES = [
"",
"A",
"Acme.Reports",
"1a",
".a",
"-a",
"_a",
"a/b",
"a b",
"a" * 97,
"aé",
]
VALID_ACTION_NAMES = ["reports", "ex-port", "a_1", "x" * 64]
INVALID_ACTION_NAMES = ["", "Reports", "1a", "a.b", "a b", "a" * 65]
VALID_SURFACE_IDS = ["reports", "my-page", "a1", "x" * 64]
INVALID_SURFACE_IDS = ["", "Reports", "1a", "my_page", "a.b", "a" * 65, "-page"]
# --- Composing -----------------------------------------------------------------
@pytest.mark.parametrize("namespace", VALID_NAMESPACES)
def test_composes_namespace_scoped_targets(namespace: str):
assert plugin_action_target(namespace, "reports") == f"{namespace}/reports"
assert plugin_page_target(namespace, "reports") == f"{namespace}/reports"
assert plugin_management_target(namespace, MANAGEMENT_READ_PART) == f"{namespace}/permissions.read"
assert plugin_management_target(namespace, MANAGEMENT_WRITE_PART) == f"{namespace}/permissions.write"
def test_management_part_constants_are_the_documented_targets():
assert MANAGEMENT_READ_PART == "permissions.read"
assert MANAGEMENT_WRITE_PART == "permissions.write"
def test_read_and_write_are_distinct_targets_on_one_resource():
"""The built-in provider ignores ``action``, so the target must differ."""
assert plugin_management_target("acme.reports", MANAGEMENT_READ_PART) != plugin_management_target("acme.reports", MANAGEMENT_WRITE_PART)
def test_action_and_page_targets_are_byte_identical_for_one_declared_name():
"""Documented ambiguity: readers must key on ``(resource, target)``."""
assert plugin_action_target("acme.reports", "reports") == plugin_page_target("acme.reports", "reports")
# --- Rejecting -----------------------------------------------------------------
@pytest.mark.parametrize("namespace", INVALID_NAMESPACES)
def test_every_constructor_rejects_an_invalid_namespace(namespace: str):
for build in (
lambda: plugin_action_target(namespace, "reports"),
lambda: plugin_page_target(namespace, "reports"),
lambda: plugin_management_target(namespace, MANAGEMENT_READ_PART),
):
with pytest.raises(PluginTargetError):
build()
@pytest.mark.parametrize("action_name", INVALID_ACTION_NAMES)
def test_action_constructor_rejects_an_invalid_action_name(action_name: str):
with pytest.raises(PluginTargetError):
plugin_action_target("acme.reports", action_name)
@pytest.mark.parametrize("surface_id", INVALID_SURFACE_IDS)
def test_page_constructor_rejects_an_invalid_surface_id(surface_id: str):
with pytest.raises(PluginTargetError):
plugin_page_target("acme.reports", surface_id)
@pytest.mark.parametrize("part", ["permissions", "read", "permissions.read ", "", "PERMISSIONS.READ", "permissions.write.extra", None, 1])
def test_management_constructor_accepts_only_the_two_documented_parts(part):
with pytest.raises(PluginTargetError):
plugin_management_target("acme.reports", part)
@pytest.mark.parametrize("namespace", VALID_NAMESPACES)
@pytest.mark.parametrize("action_name", VALID_ACTION_NAMES)
def test_valid_components_are_never_rejected(namespace: str, action_name: str):
assert plugin_action_target(namespace, action_name).startswith(f"{namespace}/")
def test_target_error_is_a_value_error():
"""Callers that only catch ``ValueError`` still fail loudly."""
assert issubclass(PluginTargetError, ValueError)
def test_action_charset_is_not_reused_for_surface_ids():
"""An underscore is legal in an action name and illegal in a surface id."""
assert plugin_action_target("acme.reports", "my_page").endswith("/my_page")
with pytest.raises(PluginTargetError):
plugin_page_target("acme.reports", "my_page")
def test_namespace_charset_is_not_reused_for_parts():
"""A dot is legal in a namespace and illegal in an action name."""
assert plugin_action_target("acme.reports", "reports").startswith("acme.reports/")
with pytest.raises(PluginTargetError):
plugin_action_target("acme.reports", "report.v2")
# --- Drift guard ---------------------------------------------------------------
def _accepted_by_plugin_targets(namespace: str) -> bool:
try:
plugin_action_target(namespace, "reports")
except PluginTargetError:
return False
return True
def _accepted_by_extension_api(namespace: str) -> bool:
from deerflow_extension_api.auth import _require_plugin_namespace
try:
_require_plugin_namespace(namespace)
except ValueError:
return False
return True
def _accepted_by_plugin_settings(namespace: str) -> bool:
from deerflow_extension_api.settings import SettingsContribution, SettingsField
from deerflow.config.plugin_settings import validate_contribution
contribution = SettingsContribution(
namespace=namespace,
title="Drift probe",
fields=(SettingsField("enabled", "Enabled", "boolean", True),),
)
try:
validate_contribution(contribution)
except ValueError:
return False
return True
def _accepted_by_tool_provenance(namespace: str) -> bool:
from deerflow.tools import tool_provenance
return tool_provenance._NAMESPACE_RE.fullmatch(namespace) is not None
@pytest.mark.parametrize("namespace", [*VALID_NAMESPACES, *INVALID_NAMESPACES])
def test_every_namespace_rule_copy_agrees(namespace: str):
expected = namespace in VALID_NAMESPACES
assert _accepted_by_plugin_targets(namespace) is expected
assert _accepted_by_extension_api(namespace) is expected
assert _accepted_by_plugin_settings(namespace) is expected
assert _accepted_by_tool_provenance(namespace) is expected

View File

@ -84,6 +84,25 @@ def test_group_filter_and_name_collision_fail_closed(installed):
build_plugin_tools(loaded, reserved_names={tool.name})
def test_built_tools_carry_host_recorded_plugin_provenance(installed):
"""The tag is written at construction, from registry-validated values."""
from deerflow.tools.tool_provenance import get_plugin_source, is_plugin_tool, resolve_tool_provenance
loaded, plugin, _ = installed
(tool,) = build_plugin_tools(loaded)
assert is_plugin_tool(tool) is True
assert get_plugin_source(tool) == {
"namespace": plugin.namespace,
"declaration": "search",
"installation": "test",
}
provenance = resolve_tool_provenance(tool)
assert provenance is not None
assert provenance.source == f"plugin:{plugin.namespace}"
assert provenance.declaration == "search"
@pytest.mark.parametrize("source", ["config", "builtin", "mcp", "acp"])
def test_assembly_keeps_ordinary_and_unaffected_plugin_tools_on_collision(installed, monkeypatch, caplog, source):
from langchain_core.tools import Tool

View File

@ -103,6 +103,7 @@ class TestResourceMapping:
("skill", "skills"),
("mcp_server", "mcp_servers"),
("route", "routes"),
("plugin_action", "plugin_actions"),
],
)
def test_reserved_request_alias_is_rejected(self, request_alias, config_key):
@ -143,6 +144,40 @@ class TestResourceMapping:
# No model policy configured → unrestricted
assert p.authorize(_make_request(resource="model", target="any")).allow is True
def test_plugin_action_maps_to_plugin_actions(self):
"""The request resource is singular; the config key is plural."""
p = _provider({"user": {"plugin_actions": {"allow": ["community.check/check"]}}})
request = _make_request(resource="plugin_action", action="invoke", target="community.check/check")
assert p.authorize(request).allow is True
assert p.authorize(_make_request(resource="plugin_action", action="invoke", target="community.check/other")).allow is False
assert p.filter_resources(Principal(role="user"), "plugin_action", ["community.check/check", "community.check/other"]) == ["community.check/check"]
def test_plugin_actions_alias_direction_denies_when_empty(self):
"""A reversed alias would silently allow; the mapping is request → config key."""
p = _provider({"user": {"plugin_actions": {"allow": []}}})
assert p.authorize(_make_request(resource="plugin_action", action="invoke", target="community.check/check")).allow is False
def test_plugin_management_is_self_mapped(self):
"""Key == resource is legal (same shape as ``sandbox``)."""
p = _provider({"user": {"plugin_management": {"allow": ["community.check/permissions.read"]}}})
allowed = p.authorize(_make_request(resource="plugin_management", action="read", target="community.check/permissions.read"))
denied = p.authorize(_make_request(resource="plugin_management", action="write", target="community.check/permissions.write"))
assert allowed.allow is True
assert denied.allow is False
def test_plugin_management_separates_read_from_write_by_target(self):
"""The built-in provider ignores ``action``, so authority must differ by target."""
from deerflow.authz.plugin_targets import MANAGEMENT_READ_PART, MANAGEMENT_WRITE_PART, plugin_management_target
read_target = plugin_management_target("community.check", MANAGEMENT_READ_PART)
write_target = plugin_management_target("community.check", MANAGEMENT_WRITE_PART)
p = _provider({"user": {"plugin_management": {"allow": [read_target]}}})
assert p.authorize(_make_request(resource="plugin_management", action="read", target=read_target)).allow is True
# The same call under the write action is still denied: the target carries the authority.
assert p.authorize(_make_request(resource="plugin_management", action="write", target=write_target)).allow is False
assert p.authorize(_make_request(resource="plugin_management", action="write", target=read_target)).allow is True
# --- Role resolution ---

View File

@ -0,0 +1,204 @@
"""Tool provenance: display attribution only, with a fixed precedence.
The plugin tag is written by the host at construction; every other source keeps
the heuristic label it had. Nothing here is a trust input — the one decision
that needs certainty (the Layer-2 infrastructure exemption) compares object
identity, not a label.
"""
from __future__ import annotations
import logging
import pytest
from langchain_core.tools import Tool
from deerflow.agents.assembly_descriptor import describe_tool
from deerflow.extensions.plugin_tools import plugin_tool_name
from deerflow.tools.mcp_metadata import MCP_TOOL_METADATA_KEY, MCP_TOOL_SOURCE_METADATA_KEY
from deerflow.tools.tool_provenance import (
PLUGIN_TOOL_METADATA_KEY,
PLUGIN_TOOL_SOURCE_METADATA_KEY,
get_plugin_source,
is_plugin_tool,
resolve_tool_provenance,
tag_plugin_tool,
tool_provenance_context,
)
NAMESPACE = "acme.reports"
INSTALLATION = "acme.plugin:install"
def _tool(module: str = "some.vendor.tools", name: str = "gadget") -> Tool:
def func(**_kwargs):
return "ok"
func.__module__ = module
return Tool(name=name, description="A tool", func=func)
def _plugin_tool(namespace: str = NAMESPACE, declaration: str = "export", *, installation: str = INSTALLATION, operation: str | None = None) -> Tool:
tool = _tool(name=plugin_tool_name(namespace, declaration))
tag_plugin_tool(tool, namespace=namespace, declaration=declaration, installation=installation, operation=operation)
return tool
# --- Tag round trip ------------------------------------------------------------
def test_plugin_tag_round_trips_into_provenance_and_context():
tool = _plugin_tool()
assert is_plugin_tool(tool) is True
provenance = resolve_tool_provenance(tool)
assert provenance is not None
assert provenance.source == f"plugin:{NAMESPACE}"
assert provenance.namespace == NAMESPACE
assert provenance.declaration == "export"
assert provenance.installation == INSTALLATION
assert provenance.operation is None
assert tool_provenance_context(tool) == {
"source": f"plugin:{NAMESPACE}",
"namespace": NAMESPACE,
"declaration": "export",
"installation": INSTALLATION,
}
def test_declared_shared_operation_is_carried_through():
tool = _plugin_tool(operation="acme.reports.export")
provenance = resolve_tool_provenance(tool)
assert provenance is not None and provenance.operation == "acme.reports.export"
assert tool_provenance_context(tool)["operation"] == "acme.reports.export"
def test_plugin_tag_is_read_by_the_assembly_descriptor():
"""The descriptor identity and the execution-time label cannot disagree."""
assert describe_tool(_plugin_tool()).source == f"plugin:{NAMESPACE}"
def test_tagging_preserves_unrelated_metadata():
tool = _tool()
tool.metadata = {"kept": "value"}
tag_plugin_tool(tool, namespace=NAMESPACE, declaration="export", installation=INSTALLATION)
assert tool.metadata["kept"] == "value"
assert tool.metadata[PLUGIN_TOOL_METADATA_KEY] is True
assert tool.metadata[PLUGIN_TOOL_SOURCE_METADATA_KEY]["declaration"] == "export"
# --- Consistency and precedence ------------------------------------------------
def test_mis_attributed_tag_is_dropped_with_a_warning(caplog: pytest.LogCaptureFixture):
"""A tag whose name is not this plugin's generated name never becomes a label."""
tool = _tool(name="gadget")
tag_plugin_tool(tool, namespace=NAMESPACE, declaration="export", installation=INSTALLATION)
with caplog.at_level(logging.WARNING):
provenance = resolve_tool_provenance(tool)
assert provenance is not None
assert provenance.source == "community" # the heuristic the tool would have had
assert provenance.namespace is None
assert "inconsistent plugin provenance tag" in caplog.text
@pytest.mark.parametrize(
"source",
[
{"namespace": "BAD NAMESPACE", "declaration": "export", "installation": INSTALLATION},
{"namespace": NAMESPACE, "declaration": "", "installation": INSTALLATION},
{"namespace": NAMESPACE, "declaration": "export", "installation": None},
{"namespace": NAMESPACE, "declaration": "export", "installation": INSTALLATION, "operation": ""},
"not-a-mapping",
],
)
def test_structurally_invalid_tags_are_not_plugin_provenance(source):
tool = _plugin_tool()
tool.metadata = {**(tool.metadata or {}), PLUGIN_TOOL_SOURCE_METADATA_KEY: source}
assert is_plugin_tool(tool) is True
assert get_plugin_source(tool) is None
provenance = resolve_tool_provenance(tool)
assert provenance is not None and provenance.source == "community"
def test_mcp_flag_wins_over_a_plugin_tag():
"""MCP tools are tagged first in the documented precedence."""
tool = _plugin_tool()
tool.metadata = {
**(tool.metadata or {}),
MCP_TOOL_METADATA_KEY: True,
MCP_TOOL_SOURCE_METADATA_KEY: {"server_name": "filesystem", "transport": "stdio"},
}
provenance = resolve_tool_provenance(tool)
assert provenance is not None
assert provenance.source == "mcp:filesystem"
assert provenance.mcp_server == "filesystem"
assert provenance.mcp_transport == "stdio"
assert provenance.namespace is None
def test_an_mcp_flag_without_source_details_stays_labelled():
tool = _tool()
tool.metadata = {MCP_TOOL_METADATA_KEY: True}
provenance = resolve_tool_provenance(tool)
assert provenance is not None and provenance.source == "mcp:unknown"
def test_declared_tool_source_is_honoured_for_an_untagged_tool():
"""Out-of-repo tool builders keep their declaration-controlled display label."""
tool = _tool()
tool.metadata = {"deerflow_tool_source": "custom-vendor"}
provenance = resolve_tool_provenance(tool)
assert provenance is not None and provenance.source == "custom-vendor"
def test_plugin_tag_is_checked_before_a_declared_source():
tool = _plugin_tool()
tool.metadata = {**(tool.metadata or {}), "deerflow_tool_source": "custom-vendor"}
provenance = resolve_tool_provenance(tool)
assert provenance is not None and provenance.source == f"plugin:{NAMESPACE}"
# --- Heuristic fallbacks (unchanged display labels) -----------------------------
@pytest.mark.parametrize(
"module,expected",
[
("deerflow.tools.builtins.web_search", "builtin"),
("deerflow.agents.memory.manager", "builtin"),
("deerflow.skills.loader", "skill"),
("some.vendor.tools", "community"),
("", "builtin"), # no usable __module__ on the callable
],
)
def test_module_heuristic_labels_are_unchanged(module: str, expected: str):
provenance = resolve_tool_provenance(_tool(module=module))
assert provenance is not None and provenance.source == expected
def test_every_real_tool_resolves_to_a_label():
"""An untagged source can never turn a label lookup into ``None``."""
for tool in (_tool(), _tool(module=""), _plugin_tool()):
provenance = resolve_tool_provenance(tool)
assert provenance is not None and provenance.source
def test_missing_tool_resolves_to_nothing():
assert resolve_tool_provenance(None) is None
assert tool_provenance_context(None) == {}
def test_context_form_omits_absent_optional_fields():
context = tool_provenance_context(_tool(module="deerflow.tools.builtins.web_search"))
assert context == {"source": "builtin"}

2
backend/uv.lock generated
View File

@ -918,7 +918,7 @@ extensions = []
[[package]]
name = "deerflow-extension-api"
version = "0.2.3"
version = "0.2.4"
source = { editable = "packages/extension-api" }
[[package]]

View File

@ -20,7 +20,7 @@
# ============================================================================
# Bump this number when the config schema changes.
# Run `make config-upgrade` to merge new fields into your local config.yaml.
config_version: 48
config_version: 49
# Optional operator-owned literal prompt extensions (see CONFIGURATION.md):
# lead_prompt_overlay:
@ -2991,6 +2991,16 @@ run_ownership:
# # (which models/skills the caller sees on GET /api/models and the
# # GET /api/skills[.../custom, /{name}] skill routes); skill
# # management endpoints (install/edit/export) stay admin-gated.
# #
# # Plugin resources: `plugin_actions` gates invoking a registered
# # backend action (POST /api/plugins/{ns}/actions/{name}), and
# # `plugin_management` gates an enterprise-contributed management route
# # that calls require_plugin_management. Targets are
# # "<namespace>/<action-name>" and "<namespace>/permissions.read"
# # / "<namespace>/permissions.write". **Omitting a key leaves that
# # resource unrestricted for the role**, so list it explicitly to
# # constrain it. Declared plugin pages are not gated yet: their
# # `plugin_pages` row ships with the page-authorization slice.
# roles:
# admin:
# tools: {allow: "*"}
@ -2998,12 +3008,16 @@ run_ownership:
# models: {allow: "*"}
# sandbox: {allow: "*"}
# skills: {allow: "*"}
# plugin_actions: {allow: "*"}
# plugin_management: {allow: "*"}
# user:
# tools: {allow: "*", deny: ["update_agent"]}
# routes: {allow: "*"}
# models: {allow: "*"}
# sandbox: {allow: "*"}
# skills: {allow: "*"}
# plugin_actions: {allow: "*"} # invoke any installed action
# plugin_management: {allow: []} # no management route access
# guest:
# # web-only role: sandbox-dependent tools (read_file, bash, glob,
# # grep, write_file, ...) are omitted — with sandbox:execute denied
@ -3014,6 +3028,8 @@ run_ownership:
# models: {allow: ["gpt-4o-mini"]}
# sandbox: {allow: false} # deny sandbox execution
# skills: {allow: []} # no skills visible in the listing
# plugin_actions: {allow: ["acme.reports/export"]}
# plugin_management: {allow: ["acme.reports/permissions.read"]}
authorization:
enabled: false

View File

@ -135,7 +135,7 @@ they resolve from the `secrets` map):
```yaml
config: |
config_version: 48
config_version: 49
models:
- name: gpt-4
use: langchain_openai:ChatOpenAI

View File

@ -249,7 +249,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: 48
config_version: 49
log_level: info
recursion_limit: 100
max_recursion_limit: 1000

View File

@ -3,7 +3,7 @@
A deployment-installed Python extension can register a `PluginContribution` with
optional browser code, authenticated backend actions and model tools. This extends
the existing `install(registry, config)` workflow. MCP and Skills keep their existing
APIs and lifecycles. Public contracts live in `deerflow_extension_api` (0.2.3).
APIs and lifecycles. Public contracts live in `deerflow_extension_api` (0.2.4).
The browser contribution API is experimental. `BrowserModule(code=...)` remains the
self-contained transport; `BrowserAssets(root=...)` adds manifest-listed resources
@ -212,6 +212,117 @@ The host does not rewrite CSS URLs or add authorization tokens to URLs. CSP must
the backend origin in the applicable `script-src`, `style-src`, `img-src`, `font-src`
and `connect-src` directives. Inline-v1 still needs `blob:` in `script-src`.
## Authorization of plugin surfaces
Fine-grained authorization (`authorization:` in `config.yaml`; the native RFC is
[`docs/plans/2026-07-10-pluggable-authorization-rfc.md`](plans/2026-07-10-pluggable-authorization-rfc.md))
is enforced on two plugin entry points when it is enabled. With
`authorization.enabled: false` both are no-ops and today's behavior is
unchanged, on every entry point.
| Entry point | `resource` | `action` | `target` |
| --- | --- | --- | --- |
| Registered backend action | `plugin_action` | `invoke` | `<namespace>/<action-name>` |
| Enterprise management route | `plugin_management` | `read` / `write` | `<namespace>/permissions.read` / `<namespace>/permissions.write` |
Targets are composed and validated by `deerflow.authz.plugin_targets`; the left
side is always a host-validated plugin namespace. Read and write management
authority are separate **targets** (the built-in provider ignores `action`), and
page access never implies write authority.
The registered-action check runs in `POST /api/plugins/{namespace}/actions/{name}`
after the action has been resolved — so an unknown action stays a 404 — and
**before** the request body is read, so a denied caller cannot consume the
256 KiB input budget or reach the handler. A denial is
`403 {"detail": "Plugin action not permitted for your role."}`; `fail_closed`
(see below) decides whether a provider failure denies or proceeds.
The built-in RBAC provider reads these from `authorization.provider.config.roles`
as `plugin_actions` and `plugin_management`. A role with no policy for a
resource is **unrestricted** for it, so list these keys explicitly wherever you
want to constrain them; `config.example.yaml` shows the shape.
### Guarding a contributed management route
A contributed router is not covered by the registered-action dispatcher, so it
asks for plugin-scoped authority itself:
```python
from deerflow_extension_api import arequire_plugin_management, require_admin
from fastapi import HTTPException, Request
@router.get("/{namespace}/permissions")
async def read_permissions(namespace: str, request: Request):
try:
principal = await arequire_plugin_management(request, namespace, scope="read")
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
... # read the enterprise's own policy store
@router.put("/{namespace}/permissions")
async def write_permissions(namespace: str, request: Request):
try:
principal = await arequire_plugin_management(request, namespace, scope="write")
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
... # validate delegation/scope, then commit
```
- The helper asks the host's configured provider, with the host's trusted
request identity; it never reads or writes enterprise policy itself.
- It **fails closed**: an unknown namespace, an anonymous caller, a missing or
failing host resolver, or a non-allowed decision raises `PermissionError`.
- That `PermissionError` is not an HTTP response. The Gateway installs no
`PermissionError` handler, so a route that lets it escape answers `500`, not
`403`; translate it at your own boundary as the example does. The host's other
contributed-route safeguards — authentication, CSRF, PAT rejection — run
before your handler and none of them covers a policy denial.
- When authorization is disabled the host answers "allow", so the helper is a
no-op and a deployment that turns authorization off does not start rejecting
enterprise routes. An enterprise that needs an unconditional floor keeps
calling `require_admin` as well.
- "Cannot answer" includes a configuration the host cannot read right now: a
config file mid-write or briefly absent while an editor, a deploy or a
ConfigMap mount replaces it, or a document that does not validate. The
decision uses the config snapshot captured when the request's provider was
resolved, and a host that is running on a configuration never reads such a
failure as "authorization is disabled": it follows the configured failure
policy, which denies under the default `fail_closed: true`. A host with no
configuration at all has no policy to apply, so the helper stays a no-op.
- Use `arequire_plugin_management` from an async endpoint. `require_plugin_management`
is the synchronous form for a FastAPI `def` endpoint, which FastAPI runs in
its thread pool; calling it from an async endpoint would do the host's
configuration read on the event loop.
- The plugin namespace must be an installed plugin's namespace, and the
decision is re-evaluated per request: an execution-time policy change is
observed by the next call.
### Documented boundaries
- **Contributed routers are not covered by the registered-action dispatcher.**
Only registered actions go through the automatic check; a contributed router
uses `require_plugin_management` for plugin-scoped checks and keeps its own
administrator floor where one is needed. Arbitrary in-process plugin code is
trusted operator-installed code and RBAC does not sandbox it.
- **Declared plugin pages are not gated yet.** A page's navigation entry, direct
route and mount are still decided by the plugin's `enabled` setting alone;
server-evaluated page authorization arrives with the page-declaration
contract. Until then a browser surface must keep checking its own backend
operations, which *are* covered above.
- **Runtime-injected tools stay Layer-2 decisions.** Tool declarations added
through `request.tools` inside `wrap_model_call`, and model calls naming a tool
absent from the bound tool set, are not part of assembly-time narrowing; the
execution-time provider decides each call by name, like every other tool call.
- **`create_deerflow_agent` (`deerflow.agents.factory`) still performs no
authorization.** Pre-existing and unchanged by this work.
- **Durable tasks keep today's contract.** Only the exposed submission wrapper is
checked; raw status/cancel service calls and their ownership checks are
unchanged.
- **The pre-existing route/model/skill authorization cache is untouched.** It
keeps its synchronous lifecycle and call sites; the plugin paths use a
separate provider cache keyed by configuration signature and event loop, so a
loop-affine provider is never handed to another loop.
## Trust and lifecycle
**Browser and Python plugins are trusted operator-installed code.** Browser modules

View File

@ -483,6 +483,221 @@ Phase 1 最低验证要求:
各追加条目。前端 effective-permissions 展示剩余项;management route 的 provider
迁移(沿袭前阶段延期项)。
### 2026-09-24 — Phase 5 / PR1 — 插件资源授权管道(targets、决策层、action 检查、management 守卫)
- **背景:** 设计文档 `2026-09-23-extension-tool-authorization-coverage-gaps.md` 与实施规格
`2026-09-24-extension-tool-authorization-spec.md`(rev 5)把「插件工具/页面授权覆盖缺口」
切成三个 PR:PR1 = 插件管道(target 编码、provenance 展示、`plugin_authz`、action 检查、
management 守卫、provider 生命周期、公开 helper + 一次契约版本提升),PR2 = 工具链路
(middleware 声明工具、identity-bound exemption),PR3 = 页面切片。本条记录 PR1。
- **决策(target 编码):** 新增 `deerflow/authz/plugin_targets.py`,复合 target 一律
`"{namespace}/{part}"`,由唯一 `_join` 校验器编码:namespace 字符集与
`config/plugin_settings.py` 一致,action / surface id / management part 分别镜像 registry、
浏览器 surface 规则和两个模块常量。三个构造器各带 kind 检查,调用点禁止字符串拼接;
非法输入抛 `PluginTargetError`(`ValueError` 子类),绝不返回 best-effort 字符串。
- **决策(RBAC 别名):** `_RESOURCE_POLICY_KEYS` 新增 `"plugin_action" → "plugin_actions"`
与 `"plugin_management" → "plugin_management"`(自映射,合法,同 `sandbox`)。左值仍是请求
`resource`,右值是 `config.yaml` 键;把 `plugin_action` 当作 config 键会在构造期被拒
(reserved request alias)。`plugin_page → plugin_pages` 随 PR3 落地——该 PR 才产生
`plugin_page` 资源,缺 key = 不受限仍是不变的语义。
- **决策(决策层):** 新增 `deerflow/authz/plugin_authz.py`:六个决策函数 +
`PluginAuthorizationError`(携带 resource / target / reason_code / fail_closed)。disabled →
no-op;显式 deny 抛错;provider 异常、解析失败、malformed decision、无 principal 一律按
`fail_closed` 抛错或 warning 放行(镜像 sandbox gate 的语义,而不是工具过滤器的静默集合
语义)。异步批量决策用 `asyncio.to_thread(filter_resources)`,因此
`AuthorizationProvider.filter_resources` 的 docstring 增加一句线程安全要求(仅文档,
三个方法和签名不变)。
- **决策(provider 生命周期):** 插件请求路径**不**复用既有 route provider cache(同步、
在事件循环上构造、仅按 config identity 命中,且无 blocking-IO anchor)。新增按
`(config signature, loop_key)` 键的缓存:同步调用者用私有 `_SYNC_SLOT`,异步按
`id(asyncio.get_running_loop())`;两者互不串用,也不跨 loop 复用;config 读取与
`resolve_authorization_provider_spec` 在线程中执行,`construct_authorization_provider`
保持在调用方 loop 上;无 single-flight 锁(允许并发冷启动重复构造,已文档化);
写入时清理已关闭 loop 的条目。
- **决策(公开 helper + 契约版本):** `deerflow_extension_api.auth` 新增
`EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY`、`EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY` 与
`require_plugin_management` / `arequire_plugin_management`(只读 `request.app.state`,保持
契约包不依赖 `deerflow`)。两者 fail closed:解析器缺失/失败/返回 `None`、未知 namespace、
非 `True` 答案都抛 `PermissionError`;`authorization.enabled: false` 时宿主回答 `True`
(no-op),因此不会开始拒绝企业路由,需要无条件底线的企业仍叠加 `require_admin`。同步形式
供 FastAPI `def` 端点(线程池)使用,异步端点使用 `a` 版本。契约版本 `0.2.3 → 0.2.4`
(`API_VERSION`、包 version、harness 精确 pin、lockfile、契约测试四处同步)。
- **决策(action 检查):** `POST /api/plugins/{ns}/actions/{name}` 在 action 解析之后
(未知 action 仍是 404,不改变存在性预言)、读取请求体之前插入一次 `plugin_action` 决策;
deny → `403 "Plugin action not permitted for your role."`,handler 不被调用,被拒调用者
无法占用 256 KiB 输入预算。
- **证据:** 新增 `tests/test_plugin_targets.py`(字符集、跨 kind 同形 target、
management part、非法输入)、`tests/test_tool_provenance.py`(plugin tag 往返、错配 tag 被丢弃、
MCP 优先级、`deerflow_tool_source` 与模块回退标签保持)、
`tests/test_plugin_action_authorization.py`(allow/deny/未授权先于 body、
fail_closed/fail_open、disabled no-op、未知 action 404、internal 身份)、
`tests/test_plugin_management_guard.py`(helper 的 fail-closed 矩阵、宿主解析器安装与
未知 namespace、disabled 放行、loop 生命周期与只读投影前置)、
`tests/blocking_io/test_plugin_authorization.py`(config/discovery 不在事件循环上,
含 red→green teeth);扩展 `test_rbac_authorization_provider.py`、`test_plugin_tools.py`、
`test_extension_api_contracts.py`。
- **兼容性:** `authorization.enabled: false` 时 action 路由与 management helper 均为 no-op;
既有工具 resource 名与 `AuthorizationProvider` Protocol 未变;`config_version` 47 → 49
(与主线的并发 bump 竞争后按「每次更新 PR 前的固定清单」重取下一个可用号,见下方合并记录;
`config.example.yaml` + Helm values + Helm README 三处同步);`config.example.yaml`
roles 新增 `plugin_actions` / `plugin_management` 两行并注明「省略 key = 该资源不受限」。
- **否决方案:** 不为节省一次构造而复用 route cache(会把一个 loop 上的 loop-affine provider
交给另一个 loop);不在请求边界拼接 target(必须走 `plugin_targets`);不在 PR1 引入
`plugin_page` 别名(没有生产者,属 PR3);不把插件检查做成第二个常开 gate。
- **延期:** PR2(工具链路:middleware 声明工具、`LayerOneOutcome` 种子、build-local view、
identity-bound infrastructure exemption、`GuardrailRequest.tool_provenance`/`tool_identity`)、
PR3(`pages`/`shared_operation` 声明门、`extensions/catalog.py`、`/api/plugins` 只读投影、
前端页面 gate、企业示例与剩余文档)。
### 2026-09-24 — Phase 5 / PR1 review round 1(PR #5842,willem-bd)
- **背景:** 静态审查对 PR1 提出两条授权缺陷:P1 —— 决策层为了判断 `enabled`/`fail_closed`
会**第二次**读取配置,而 Gateway 已经用可读配置解析出 provider/principal;这次重读若在
热重载窗口内失败(文件缺失/暂时非法),`_enabled_config(None)` 会把「配置不可用」当作
「授权已关闭」,于是受保护的插件 action/management 请求在未询问 `fail_closed` 的情况下被放行。
P2 —— `AuthzDecision` 是普通 dataclass,自定义 provider 可以返回
`AuthzDecision(allow="false")`;`isinstance` 校验通过后真值字符串被当作 allow。
- **决策(P1,采纳审查建议的第一种修法):** 决策层不再自行读取配置。`plugin_authz` 的六个函数
把 `app_config` 改为**必填**,语义是「调用方用于解析 provider 的那个请求级快照」;
§5.3-C 的两个 Gateway 解析辅助函数改为返回 `(provider, principal, app_config)` 三元组,
并把同一快照交给执行层,因此一次请求只有一次配置读取,也不存在两次读取之间的状态漂移。
快照为 `None`(调用方完全读不到配置)时,视为**不可用**而非「已关闭」:
以 `authz.config_unavailable` + `fail_closed=True` 失败关闭(能放行的那个开关本身读不到)。
同时 Gateway 侧新增 `_plugin_app_config[_async]`,把「不存在 config.yaml」(`FileNotFoundError`
→ 今日行为,无门禁) 与「配置存在但当前读不了/解析不了」(异常上抛 → 拒绝)区分开;
后者不再被 `safe_app_config` 的宽泛 `except Exception` 吞掉。
- **决策(P2):** 新增 `_validated_decision`,要求返回值是 `AuthzDecision` 且
`type(decision.allow) is bool`(同步 `authorize` 与异步 `aauthorize` 两条路径都走它);
非 bool 的 `allow` 与 `AuthzDecision` 类型错误一样按 provider 失败处理,遵循 `fail_closed`。
- **证据:** 新增回归测试并做了反证(revert 修复后必须变红)——
`test_unreadable_config_denies_the_action`(200 vs 403)、
`test_installed_resolver_denies_when_the_config_cannot_be_read`(True vs False)、
`test_a_non_bool_allow_is_a_malformed_decision[...]`(12 项红)与对应异步/路由用例、
`test_installed_resolver_allows_when_no_config_exists`(不存在 config.yaml 仍为放行)、
`test_no_config_at_all_keeps_todays_behavior`。全部 95 项插件套件在修复后通过。
- **兼容性:** `authorization.enabled: false` 与「没有 config.yaml」两种情形行为不变;
harness 内部函数签名由「可选 app_config」变为「必填快照」,属 PR1 内的内部契约调整,
不涉及 `deerflow_extension_api` 公开面(公开 helper 的 fail-closed 语义只在“宿主读不到配置”
时更严格)。
- **延期:** 不变(工具链路 PR2、页面切片 PR3)。
### 2026-09-25 — Phase 5 / PR1 review round 2(PR #5842,willem-bd)
- **背景:** 静态审查对当前 head(`62197d96`)再提两条:P1 —— `_plugin_app_config` 仍把
`FileNotFoundError`(请求时刻文件不在)映射成「授权从未启用」并返回 `None`,调用方据此放行
受保护的插件 action 与 management 路由;但真实 Gateway 不可能在没有可读 `config.yaml` 的情况下
启动(`app/gateway/app.py` 的 `lifespan` 用同一个访问器读配置,失败即 `RuntimeError`),
因此请求期的「缺失」只意味着这份配置在运行中变得不可用(热重载 / 原子替换窗口)。
P2 —— `_deny_reason_code` 在 provider 校验的 `try` 之外遍历 `decision.reasons`,
自定义 provider 返回 `AuthzDecision(allow=False, reasons=None)`(或任何不可迭代对象)时抛
`TypeError`,把一次拒绝变成未捕获错误,而不是配置好的授权失败(403)。
- **决策(P1,采纳审查建议的第一种修法):** 用「这个进程是否在跑一份配置」区分「不可用」与「未配置」,
而不是用异常类型或无条件失败关闭:
- 读取失败且进程**从未加载过配置**(`peek_loaded_app_config() is None`;`deerflow.config.app_config`
新增只读访问器,覆盖 `get_app_config()` 成功缓存与 `set_app_config()` 注入两种来源):授权只能由
配置开启,故**无门禁**放行——与同一文件里的 `_get_route_authorization_config()`
(`(FileNotFoundError, RuntimeError)` → disabled)和 `sandbox_authz.safe_app_config()`
(“no readable config ⇒ the sandbox gate is a no-op … CI runners and direct-call tests”)同一条规则。
挂载 plugins router 的 e2e 宿主(`backend/extension_test_fixtures/bookmark_plugin_gateway.py`)
正属此类。
- 读取失败且进程**正在跑一份配置**:这是「可用但当前读不到」,异常上抛,
`resolve_/aresolve_plugin_authorization` 以 `_PluginAuthorizationUnavailable(fail_closed=…)`
失败处理,标志由**那份配置自己**给出(`enabled` 非 `True` → 无门禁;enabled → 取该策略的
`fail_closed`,默认 `true`)。于是启用授权且 fail-closed 的宿主在窗口内返回 403 / `False`,
不再像以前那样被当成「授权从未开启」放行。
- 文件存在但读不了/解析不了(非 `FileNotFoundError`):无已加载配置时沿用 round 1 的 fail-closed
(能放行的那个开关读不到);有已加载配置时同样取该策略的 `fail_closed`。
*被取代的规则:* round 1 记录的「不存在 `config.yaml` → 今日行为,无门禁」,以及本轮中间版本的
「任何读取失败都无条件失败关闭(`fail_closed=True`)」。
- **修正记录:** 本轮先按审查字面实现「缺失即无条件失败关闭」,本地与 CI e2e 立刻变红
(`frontend/tests/e2e/bookmark-plugin.spec.ts` 的 save action 由 200 变 403——该宿主本来就
没有 `config.yaml`),且与 `_get_route_authorization_config()`、`safe_app_config()` 的既有规则
冲突。随后改为上面的「按进程已加载的配置」判定:没有配置的宿主行为不变,丢失配置的宿主不再被
当成未配置。
- **决策(P2):** 拒绝码提取改为全函数:`reasons` 不可迭代时直接退化为 `authz.denied`,
元素校验额外要求 `reason.code` 是非空 `str`。刻意**不**并入 `_validated_decision` 的「畸形判决」
判定:`allow` 已是真正的 `bool`,这条判决本身是合法拒绝;若按畸形判决走 `fail_closed`,
在 `fail_closed: false` 下会把明确拒绝翻成放行。
- **证据:** 新增/改写回归测试并做了反证(修复前必须变红)——
`test_installed_resolver_allows_when_no_config_exists`(无配置宿主:True)、
`test_installed_resolver_applies_the_running_policy_when_the_config_disappears[True/False]`、
`test_installed_async_resolver_denies_when_the_config_disappears`、
`test_installed_resolver_stays_noop_when_the_lost_config_had_authorization_off`、
`test_a_lost_config_follows_the_running_policy[403/200]`、`test_no_config_at_all_keeps_todays_behavior`、
`test_peek_loaded_app_config_survives_a_missing_file`(文件删除后 `peek_loaded_app_config()`
仍返回已加载配置),以及 round 1 的 `test_unreadable_config_denies_the_action` /
`test_installed_resolver_denies_when_the_config_cannot_be_read`(非 `FileNotFoundError` 仍然 fail-closed);
P2 侧 `test_malformed_reasons_keep_the_denial[...]` / `test_async_malformed_reasons_keep_the_denial[...]`
(`TypeError: 'NoneType' object is not iterable`,`plugin_authz.py:113`)。
e2e 层面复现了 spec 的后端一半:拉起 `extension_test_fixtures.bookmark_plugin_gateway` 后
POST `/api/plugins/community.bookmarks/actions/save`,中间版本 `403`(与 CI 的 3 个 bookmark 用例
失败一致),最终版本 `200`。验证:配置模块测试 41 项、插件/extension + blocking-IO 429 项、
`ruff check` 与 `format --check` 全绿。
- **否决方案:** ①「缺失即无条件失败关闭」:与既有 `safe_app_config` / `_get_route_authorization_config`
规则冲突,并打断 e2e 宿主的插件 action(见上)。②「Gateway 侧发布 startup 快照」:状态要手动接线、
且只反映启动那一刻的策略(热重载后的新策略不会生效),改用配置模块自己缓存的「最后一次成功加载」
更准确且无需额外生命周期钩子。
- **兼容性:** 没有配置的宿主(CI runner、直接调用、只挂 plugins router 的宿主)行为完全不变;
配置可读且 `authorization.enabled: false` 时不变(no-op);只有「运行中配置消失/读不了」由
静默放行改为按该策略的 `fail_closed` 处理(默认拒绝)。
- **延期:** 不变(工具链路 PR2、页面切片 PR3)。相邻风险已记录但未改:同一文件里的
`_get_route_authorization_config()`(model / skill / sandbox 路由门)对读取失败仍一律回退到
disabled,存在同类窗口;如需同样收紧应另开一条(影响面覆盖全部路由门,超出本轮范围)。
### 2026-09-25 — Phase 5 / PR1 review round 3(PR #5842,willem-bd)
- **背景:** 静态审查对当前 head(`a891e704`)追加一条 P3 —— round 2 的 P2 修复只挡住了
「`reasons` 不是 `Iterable`」,遍历本身仍在 provider 校验的 `try` 之外:生成器或自定义可迭代
对象可以满足 `isinstance(reasons, Iterable)`,却在 `__iter__`/`__next__` 中抛错,于是一次明确
拒绝仍会以未捕获错误(500)逃逸,而不是按配置的授权失败退化为 `authz.denied`。
- **决策(采纳审查建议的第一种修法):** `_deny_reason_code` 的**整段提取**(取属性、`Iterable`
判定、遍历)包进一个窄 `try/except Exception`,任何异常都降级为 `authz.denied`,并记一条
`logger.debug(..., exc_info=True)`;函数签名、返回值域与「拒绝仍是拒绝」的语义不变。
不采纳第二种修法(把 `reasons` 校验成声明的具体 list 形状):合法 provider 返回 tuple 或生成器
形态的 `AuthzReason` 时,那会把**本来可读**的拒绝码一并降级为 `authz.denied`,属无谓的行为收窄;
「`Iterable` 判定 + 全函数提取」既保留合法可迭代对象,又对抛错对象收敛。
- **证据:** 红先验证(修复前必须变红)——管理器层
`test_malformed_reasons_keep_the_denial[True|False-reasons2|reasons3]`(4 项)与异步
`test_async_malformed_reasons_keep_the_denial[reasons2|reasons3]`(2 项)修复前抛
`RuntimeError: reasons.__iter__ failed` / `RuntimeError: reasons.__next__ failed`
(`plugin_authz.py:127`),修复后降级为
`PluginAuthorizationError(reason_code="authz.denied")`;新增的 `_ExplodingReasons` 覆盖两种形态:
`__iter__` 直接抛,以及先产出一个非 `AuthzReason` 再从 `__next__` 抛。路由层
`test_unreadable_reasons_deny_instead_of_erroring[True|False]` 修复前让异常穿出路由(未捕获),
修复后 `403` 且 handler 未被调用——两种 `fail_closed` 取值下都保持拒绝
(`fail_closed: false` 不得把明确拒绝翻成放行)。验证:两个插件测试文件 115 项、
`tests/blocking_io` 163 项、`ruff check` 与 `ruff format --check` 全绿。
- **兼容性:** 拒绝码提取的返回值域不变(`str`,无法读取时为 `authz.denied`);只有「可迭代对象在
遍历时抛错」这一种输入从 500 变为降级后的 403。
- **延期:** 不变(工具链路 PR2、页面切片 PR3)。相邻同类风险已记录但未改:
`deerflow/authz/adapter.py` 的 `_to_guardrail` 仍直接遍历 `d.reasons`,而消费它的
`GuardrailMiddleware` 把 provider 异常按 `fail_closed` 处理,因此 `fail_closed: false` 时
「显式拒绝 + reasons 遍历抛错」可能被翻成放行;该文件不在本 PR 面内(工具链路),
如需同样收敛应随 PR2 一并处理。
### 2026-09-25 — Phase 5 / PR1 合并主线 `3a862780`(config_version 竞争)
- **背景:** 合并最新 `upstream/main`(`3a862780`)时只有 `config.example.yaml` 一处文本冲突:
冲突块本身是主线新增的 `lead_prompt_overlay` 注释段(本 PR 该位置为空),取主线即可。
真正的语义问题是 `config_version`:merge-base 为 47,本 PR 与主线**各自** bump 到同一个 48
(本 PR 为 `authorization` / roles 新增字段,主线为 prompt overlay 等)。该字段只驱动
`AppConfig._check_config_version()` 的过期提示(`backend/docs/CONFIGURATION.md`),两边同号会让
「已从主线 48 升级过的 `config.yaml`」收不到缺 `authorization` 字段的提示。
- **决策:** 按本文件「每次更新 PR 前的固定清单」执行:先 fetch 最新 `upstream/main`、读最新值
(48),取下一个可用号 **49**,并在三处镜像同步(`config.example.yaml`、
`deploy/helm/deer-flow/values.yaml`、`deploy/helm/deer-flow/README.md`);
`frontend/src/content/{en,zh}/harness/checkpoints/reference.mdx` 里「current `config_version`」
也一并改为 49(该文档由主线新增,写成时值为 47)。冲突块取主线;本 PR 的 `authorization:`
段与 `peek_loaded_app_config()` 均在自动合并结果中保留。
- **证据:** `scripts/check_config_version.sh` 通过(example=49 / chart=49);
`backend/tests/test_config_version.py` 全绿(含用真实 `scripts/config-upgrade.sh` 跑
v26 → 当前版本的升级用例,其 `expected_version` 直接读 `config.example.yaml`)。
- **兼容性:** 仅提示语义变化,运行时行为不变。已按主线 48 升级的 `config.yaml` 会收到一次
“outdated” 提示,可 `make config-upgrade` 合并 `authorization` 默认段——这正是版本号存在的目的。
- **延期:** 不变(工具链路 PR2、页面切片 PR3)。相邻且非本轮产生的文档漂移未改:上述
checkpoints 文档里的 `appcfg:531-575` / `appcfg:570-575` 行号引用在主线自己新增 prompt overlay
后已失准(当前 `_check_config_version` 位于 `appcfg:534-576`),不属本 PR 面内。
### 新记录模板
```markdown

View File

@ -103,7 +103,7 @@ No environment variable selects the checkpoint mode or its cadence. Both come on
### Config version
The schema change shipped with `config_version 27`; `config.example.yaml` is currently `config_version: 47` and a missing version counts as `0` (`example:23`, `appcfg:531-575`). An outdated `config.yaml` logs:
The schema change shipped with `config_version 27`; `config.example.yaml` is currently `config_version: 49` and a missing version counts as `0` (`example:23`, `appcfg:531-575`). An outdated `config.yaml` logs:
```text
Your config.yaml (version %d) is outdated — the latest version is %d. Run `make config-upgrade` to merge new fields into your config.

View File

@ -103,7 +103,7 @@ The extension manager asks for confirmation before installing, rejects source UR
## Versioning
The contract package is versioned separately from DeerFlow, and a host exposes its version as `deerflow_extension_api.API_VERSION`. This manual covers `deerflow-extension-api` **0.2.3**.
The contract package is versioned separately from DeerFlow, and a host exposes its version as `deerflow_extension_api.API_VERSION`. This manual covers `deerflow-extension-api` **0.2.4**.
- Before 1.0, a minor release may break extensions and a patch release only adds. From 1.0 on, breaking changes bump the major.
- Every Protocol method has a default implementation and every optional dataclass field has a default, so additive releases do not break already-released extensions.

View File

@ -1,13 +1,13 @@
---
title: Reference
description: Every public name in deerflow-extension-api 0.2.3, grouped by topic, with signatures, fields, and defaults. Also covers the compatibility rules and the contract's version history.
description: Every public name in deerflow-extension-api 0.2.4, grouped by topic, with signatures, fields, and defaults. Also covers the compatibility rules and the contract's version history.
---
import { Callout } from "nextra/components";
# Reference
This page lists every name in `deerflow_extension_api.__all__` for contract version **0.2.3**. Import them from the package root:
This page lists every name in `deerflow_extension_api.__all__` for contract version **0.2.4**. Import them from the package root:
```python
from deerflow_extension_api import ExtensionRegistry, MiddlewarePlacement, Placement
@ -346,6 +346,26 @@ Return the principal if it is an administrator, otherwise raise `PermissionError
`"deerflow_extension_principal_resolver"`. The `app.state` attribute the host installs its resolver under. Host-owned.
### `require_plugin_management(request: object, namespace: str, *, scope: Literal["read", "write"] = "read") -> ExtensionPrincipal`
Return the caller when it holds `plugin_management` **scope** for an installed plugin *namespace*; otherwise raise `PermissionError`. Use it in a contributed management route before reading (`scope="read"`) or changing (`scope="write"`) the enterprise's own policy store. The host asks its configured authorization provider with the caller's trusted identity, so read and write are separate decisions and neither is implied by page access or by a tool grant.
It fails closed: an unknown namespace, an anonymous caller, a missing or failing host resolver, and a non-allowed decision all raise `PermissionError`. A host that is running on a configuration and can no longer read it follows the configured failure policy — denied under the default `fail_closed: true`. A host with `authorization.enabled: false`, or one with no configuration at all, answers "allow", so the helper is a no-op and does not start rejecting routes; an enterprise that needs an unconditional floor keeps calling `require_admin` as well. A `scope` other than `"read"`/`"write"`, or a namespace outside `[a-z][a-z0-9_.-]{0,95}`, raises `ValueError` before any decision.
This form is synchronous. Call it from a FastAPI `def` endpoint (executed in the thread pool), not from an async endpoint.
### `arequire_plugin_management(request: object, namespace: str, *, scope: Literal["read", "write"] = "read") -> ExtensionPrincipal`
The async form of `require_plugin_management`, for `async def` endpoints. It awaits the host's async resolver and never falls back to the synchronous one, so the host's configuration read stays off the event loop.
### `EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY`
`"deerflow_extension_plugin_authz_resolver"`. The `app.state` attribute the host installs the synchronous `plugin_management` resolver under, answering `(request, namespace, scope) -> bool | None`. Host-owned.
### `EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY`
`"deerflow_extension_plugin_authz_resolver_async"`. The async counterpart, answering `async (request, namespace, scope) -> bool | None`. Host-owned.
## Message provenance
Middleware that injects messages stamps them, so an observer can tell an injected message from the user's own without matching on wording.
@ -400,7 +420,7 @@ SHA-256 hex digest of `canonical_json(value)` encoded as UTF-8.
## Full-stack plugins (experimental)
<Callout type="warning">
The plugin contract is experimental in 0.2.3. Always check the return value of
The plugin contract is experimental in 0.2.4. Always check the return value of
`registry.plugin(...)`. See [Plugins](/docs/harness/extensions/plugins).
</Callout>
@ -482,14 +502,14 @@ Frozen dataclass describing one non-secret deployment setting.
### `API_VERSION`
The host's contract version as a dotted string, `"0.2.3"` for the contract this page describes. Always equal to the package version in `backend/packages/extension-api/pyproject.toml`.
The host's contract version as a dotted string, `"0.2.4"` for the contract this page describes. Always equal to the package version in `backend/packages/extension-api/pyproject.toml`.
## Compatibility rules
- **Additive growth.** Every Protocol method has a default implementation and every optional dataclass field has a default. A contract release that adds a method or field does not break extensions built against an earlier one.
- **Before 1.0**, a minor release may break extensions and a patch release is additive. **From 1.0 on**, breaking changes bump the major.
- **The `@extension(api=...)` check.** When an install function carries a marker, the host refuses it unless:
- before 1.0: the same major and minor, and the host's version is at least the declared one (a `0.2.3` host accepts `0.2.0` through `0.2.3`, and rejects `0.2.4`, `0.1.x`, and `0.3.x`);
- before 1.0: the same major and minor, and the host's version is at least the declared one (a `0.2.4` host accepts `0.2.0` through `0.2.4`, and rejects `0.2.5`, `0.1.x`, and `0.3.x`);
- from 1.0: the same major, and the host's version is at least the declared one.
A marker that is not a dotted numeric string is refused. An install function without a marker is not checked.
@ -507,5 +527,6 @@ The host's contract version as a dotted string, `"0.2.3"` for the contract this
| 0.2.1 | [#5405](https://github.com/bytedance/deer-flow/pull/5405) | `ExtensionRuntimeDeps.run_evidence_reader` with `RunEvidenceReader`, `RunPage`, `RunEventPage`, `RunStatusView`, `RunEventView`, `InvalidRunEvidenceCursor` |
| 0.2.2 | [#5647](https://github.com/bytedance/deer-flow/pull/5647) | Experimental `registry.plugin()` with `PluginContribution`, `BrowserModule`, `BackendAction`, `ModelTool`, `ActionContext`, `ToolContext`, `SettingsField` |
| 0.2.3 | [#5727](https://github.com/bytedance/deer-flow/pull/5727), [#5685](https://github.com/bytedance/deer-flow/pull/5685) | Request-scoped run evidence: `resolve_run_evidence_reader`, `require_run_evidence_reader`, `RUN_EVIDENCE_READER_RESOLVER_KEY` (#5727). Packaged browser resources: `BrowserAssets`, and `PluginContribution.frontend` also accepts it (#5685) |
| 0.2.4 | [#5842](https://github.com/bytedance/deer-flow/pull/5842) | Plugin resource authorization: `require_plugin_management`, `arequire_plugin_management`, `EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY`, `EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY` |
No release has removed a public name.

View File

@ -103,7 +103,7 @@ sharing one checkpoint database must use the same value."
### 配置版本
该 schema 变更随 `config_version 27` 发布;`config.example.yaml` 当前是 `config_version: 47`,缺少版本号按 `0` 处理(`example:23`、`appcfg:531-575`)。过旧的 `config.yaml` 会记录:
该 schema 变更随 `config_version 27` 发布;`config.example.yaml` 当前是 `config_version: 49`,缺少版本号按 `0` 处理(`example:23`、`appcfg:531-575`)。过旧的 `config.yaml` 会记录:
```text
Your config.yaml (version %d) is outdated — the latest version is %d. Run `make config-upgrade` to merge new fields into your config.

View File

@ -100,7 +100,7 @@ plugins:
## 版本
契约包独立于 DeerFlow 进行版本管理,宿主通过 `deerflow_extension_api.API_VERSION` 暴露其版本。本手册覆盖 `deerflow-extension-api` **0.2.3**。
契约包独立于 DeerFlow 进行版本管理,宿主通过 `deerflow_extension_api.API_VERSION` 暴露其版本。本手册覆盖 `deerflow-extension-api` **0.2.4**。
- 1.0 之前,minor 版本可以有不兼容变更,patch 版本只做增量。1.0 之后,不兼容变更会升 major。
- 每个 Protocol 方法都有默认实现,每个可选的 dataclass 字段都有默认值,所以增量发布不会破坏已经发布的扩展。

View File

@ -1,13 +1,13 @@
---
title: 参考
description: deerflow-extension-api 0.2.3 中的每一个公开名称,按主题分组,附签名、字段和默认值。另含兼容性规则和契约的版本历史。
description: deerflow-extension-api 0.2.4 中的每一个公开名称,按主题分组,附签名、字段和默认值。另含兼容性规则和契约的版本历史。
---
import { Callout } from "nextra/components";
# 参考
本页列出契约版本 **0.2.3** 中 `deerflow_extension_api.__all__` 的每一个名称。请从包根导入:
本页列出契约版本 **0.2.4** 中 `deerflow_extension_api.__all__` 的每一个名称。请从包根导入:
```python
from deerflow_extension_api import ExtensionRegistry, MiddlewarePlacement, Placement
@ -346,6 +346,26 @@ Gateway 的实现接受 1 到 2000 的 `limit` 和非负的 `after_seq`,否则
`"deerflow_extension_principal_resolver"`。宿主安装解析器所用的 `app.state` 属性名,由宿主拥有。
### `require_plugin_management(request: object, namespace: str, *, scope: Literal["read", "write"] = "read") -> ExtensionPrincipal`
当调用者对已安装插件的 *namespace* 拥有 `plugin_management` 的指定 **scope** 时返回该主体,否则抛出 `PermissionError`。在自有的管理路由里,读取(`scope="read"`)或修改(`scope="write"`)企业自己的策略存储之前调用它。宿主会用调用者的可信身份询问其配置的授权 provider,因此读和写是两个独立决策,且都不由页面访问或工具授权隐含。
它按失败关闭(fail closed):未知 namespace、匿名调用者、宿主解析器缺失或失败、以及非允许的决策都会抛 `PermissionError`。宿主正在运行一份配置、但当前读不到它时,走配置的失败策略——默认 `fail_closed: true` 即拒绝。当宿主 `authorization.enabled: false`,或宿主根本没有配置时,答案恒为「允许」,因此该 helper 是 no-op,不会开始拒绝路由;需要无条件底线的企业仍应同时调用 `require_admin`。`scope` 不是 `"read"`/`"write"`,或 namespace 不符合 `[a-z][a-z0-9_.-]{0,95}`,都会在任何决策之前抛 `ValueError`。
这是同步形式。请在 FastAPI 的 `def` 端点(在线程池中执行)中调用,不要在 async 端点中使用。
### `arequire_plugin_management(request: object, namespace: str, *, scope: Literal["read", "write"] = "read") -> ExtensionPrincipal`
`require_plugin_management` 的异步形式,供 `async def` 端点使用。它 await 宿主的异步解析器,且从不回退到同步解析器,因此宿主的配置读取始终不占用事件循环。
### `EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY`
`"deerflow_extension_plugin_authz_resolver"`。宿主安装同步 `plugin_management` 解析器所用的 `app.state` 属性名,签名为 `(request, namespace, scope) -> bool | None`。由宿主拥有。
### `EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY`
`"deerflow_extension_plugin_authz_resolver_async"`。对应的异步解析器,签名为 `async (request, namespace, scope) -> bool | None`。由宿主拥有。
## 消息来源标记
注入消息的中间件会给消息打上标记,这样观察者无需匹配措辞就能区分注入的消息和用户自己的消息。
@ -400,7 +420,7 @@ class ReleasePolicyProvider(Protocol):
## 全栈插件(实验性)
<Callout type="warning">
插件契约在 0.2.3 中是实验性的。务必检查 `registry.plugin(...)` 的返回值。见[插件](/docs/harness/extensions/plugins)。
插件契约在 0.2.4 中是实验性的。务必检查 `registry.plugin(...)` 的返回值。见[插件](/docs/harness/extensions/plugins)。
</Callout>
### `PluginContribution`
@ -481,14 +501,14 @@ class ReleasePolicyProvider(Protocol):
### `API_VERSION`
宿主的契约版本,为点分字符串,本页描述的契约是 `"0.2.3"`。始终等于 `backend/packages/extension-api/pyproject.toml` 中的包版本。
宿主的契约版本,为点分字符串,本页描述的契约是 `"0.2.4"`。始终等于 `backend/packages/extension-api/pyproject.toml` 中的包版本。
## 兼容性规则
- **增量演进。** 每个 Protocol 方法都有默认实现,每个可选的 dataclass 字段都有默认值。新增方法或字段的契约发布不会破坏基于更早版本构建的扩展。
- **1.0 之前**,minor 版本可以有不兼容变更,patch 版本只做增量。**1.0 之后**,不兼容变更会升 major。
- **`@extension(api=...)` 检查。** install 函数带有标记时,宿主只在以下情况接受它:
- 1.0 之前:major 和 minor 相同,且宿主版本不低于声明版本(`0.2.3` 宿主接受 `0.2.0` 到 `0.2.3`,拒绝 `0.2.4`、`0.1.x` 和 `0.3.x`);
- 1.0 之前:major 和 minor 相同,且宿主版本不低于声明版本(`0.2.4` 宿主接受 `0.2.0` 到 `0.2.4`,拒绝 `0.2.5`、`0.1.x` 和 `0.3.x`);
- 1.0 之后:major 相同,且宿主版本不低于声明版本。
不是点分数字字符串的标记会被拒绝。没有标记的 install 函数不做检查。
@ -506,5 +526,6 @@ class ReleasePolicyProvider(Protocol):
| 0.2.1 | [#5405](https://github.com/bytedance/deer-flow/pull/5405) | `ExtensionRuntimeDeps.run_evidence_reader`,以及 `RunEvidenceReader`、`RunPage`、`RunEventPage`、`RunStatusView`、`RunEventView`、`InvalidRunEvidenceCursor` |
| 0.2.2 | [#5647](https://github.com/bytedance/deer-flow/pull/5647) | 实验性的 `registry.plugin()`,以及 `PluginContribution`、`BrowserModule`、`BackendAction`、`ModelTool`、`ActionContext`、`ToolContext`、`SettingsField` |
| 0.2.3 | [#5727](https://github.com/bytedance/deer-flow/pull/5727)、[#5685](https://github.com/bytedance/deer-flow/pull/5685) | 请求级运行证据:`resolve_run_evidence_reader`、`require_run_evidence_reader`、`RUN_EVIDENCE_READER_RESOLVER_KEY`(#5727)。打包的浏览器资源:`BrowserAssets`,`PluginContribution.frontend` 也接受它(#5685) |
| 0.2.4 | [#5842](https://github.com/bytedance/deer-flow/pull/5842) | 插件资源授权:`require_plugin_management`、`arequire_plugin_management`、`EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY`、`EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY` |
至今没有任何版本移除过公开名称。