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

143 lines
6.1 KiB
Python

"""The caller's identity, for contributed routes.
Contributed routers are constructed during install(), long before any request
exists, so identity cannot be handed to them at registration time. The host
instead installs a resolver on ``app.state`` and this module reads it back.
``request`` is duck-typed rather than annotated as a Starlette Request: this
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)
class ExtensionPrincipal:
user_id: str
is_admin: bool = False
is_internal: bool = False
roles: tuple[str, ...] = field(default_factory=tuple)
def resolve_principal(request: object) -> ExtensionPrincipal | None:
"""Return the caller's principal, or ``None`` when it cannot be determined."""
app = getattr(request, "app", None)
state = getattr(app, "state", None)
resolver = getattr(state, EXTENSION_PRINCIPAL_RESOLVER_KEY, None)
if not callable(resolver):
return None
try:
principal = resolver(request)
except Exception as exc: # noqa: BLE001 - an unanswerable identity is not an error to propagate
logger.warning("extension principal resolver failed: %s", type(exc).__name__)
return None
return principal if isinstance(principal, ExtensionPrincipal) else None
def require_admin(request: object) -> ExtensionPrincipal:
"""Return the principal when it is an admin, else raise ``PermissionError``.
Fails closed on an absent or failing resolver: an authorization question the
host cannot answer must never resolve to "allowed".
"""
principal = resolve_principal(request)
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