Zeren Wang 82671596f3
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.
2026-09-25 14:46:12 +08:00

219 lines
8.5 KiB
Python

"""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