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

147 lines
6.9 KiB
Python

"""Authenticated discovery and execution for deployment-installed full-stack plugins."""
import asyncio
import hashlib
import json
import logging
from types import MappingProxyType
from deerflow_extension_api.auth import resolve_principal
from fastapi import APIRouter, HTTPException, Request, Response
from deerflow.extensions.browser_assets import LoadedBrowserAssets, valid_asset_path
from deerflow.extensions.plugin_tools import plugin_settings
router = APIRouter(prefix="/api/plugins", tags=["plugins"])
logger = logging.getLogger(__name__)
def _principal(request):
principal = resolve_principal(request)
if principal is None:
raise HTTPException(401, "Authentication required.")
return principal
@router.get("")
async def list_plugins(request: Request, response: Response):
principal = _principal(request)
response.headers["Cache-Control"] = "private, no-store"
entries = []
for source, plugin in request.app.state.extensions.plugins:
settings = plugin_settings(source, plugin)
module = plugin.frontend
if isinstance(module, LoadedBrowserAssets):
revision = module.revision
entry = f"/api/plugins/{plugin.namespace}/assets/{revision}/{module.entry}"
transport = "assets-v1"
else:
revision = hashlib.sha256(module.code.encode()).hexdigest() if module else None
entry = f"/api/plugins/modules/{module.module}/{revision}.mjs" if module else None
transport = "inline-v1" if module else None
public = ("enabled", *module.public_fields) if module else ("enabled",)
entries.append(
{
"namespace": plugin.namespace,
"title": plugin.title,
"description": plugin.description,
"viewer_id": principal.user_id,
"module": module.module if module else None,
"entry": entry,
"transport": transport,
"settings": {key: settings[key] for key in public},
"backend_actions": [action.name for action in plugin.backend],
}
)
return entries
@router.get("/modules/{module}/{revision}.mjs")
async def plugin_module(request: Request, module: str, revision: str):
_principal(request)
for _, plugin in request.app.state.extensions.plugins:
if plugin.frontend and not isinstance(plugin.frontend, LoadedBrowserAssets) and plugin.frontend.module == module:
code = plugin.frontend.code.encode()
if hashlib.sha256(code).hexdigest() == revision:
return Response(code, media_type="text/javascript", headers={"Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff"})
raise HTTPException(404, "Plugin module unavailable; reload the page.")
@router.get("/{namespace}/assets/{revision}/{path:path}")
async def plugin_asset(request: Request, namespace: str, revision: str, path: str):
_principal(request)
if valid_asset_path(path):
for _, plugin in request.app.state.extensions.plugins:
module = plugin.frontend
if plugin.namespace == namespace and isinstance(module, LoadedBrowserAssets) and module.revision == revision:
asset = module.files.get(path)
if asset is not None:
return Response(
asset.content,
media_type=asset.media_type,
headers={
"Cache-Control": "private, max-age=31536000, immutable",
"Vary": "Cookie, Authorization",
"X-Content-Type-Options": "nosniff",
# Assets can also be opened as documents (notably SVG).
"Content-Security-Policy": "sandbox",
},
)
raise HTTPException(404, "Plugin asset unavailable; reload the page.", headers={"Cache-Control": "private, no-store"})
@router.post("/{namespace}/actions/{action_name}")
async def invoke_plugin_action(request: Request, namespace: str, action_name: str):
"""Invoke an installed action with the authenticated viewer and deployment settings."""
from deerflow_extension_api.auth import resolve_principal
from deerflow_extension_api.plugins import ActionContext
from deerflow.extensions.plugin_tools import plugin_settings
principal = resolve_principal(request)
if principal is None:
raise HTTPException(401, "Authentication required.")
if request.headers.get("x-deerflow-plugin-viewer") not in (None, principal.user_id):
raise HTTPException(409, "Account changed; reload this plugin view.")
found = next(((source, plugin) for source, plugin in request.app.state.extensions.plugins if plugin.namespace == namespace), None)
if found is None:
raise HTTPException(404, "Plugin is not installed.")
source, plugin = found
action = next((item for item in plugin.backend if item.name == action_name), None)
if action is None:
raise HTTPException(404, "Plugin action is not installed.")
try:
settings = await asyncio.to_thread(plugin_settings, source, plugin)
except (ValueError, OSError) as exc:
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)
if len(body) > 256 * 1024:
raise HTTPException(413, "Plugin action input exceeds 256 KiB.")
try:
payload = json.loads(body)
if not isinstance(payload, dict):
raise ValueError("Expected an object")
except (ValueError, UnicodeDecodeError) as exc:
raise HTTPException(422, "Plugin action requires a JSON object.") from exc
try:
async with asyncio.timeout(30):
return await action.handler(MappingProxyType(payload), ActionContext(principal, MappingProxyType(settings)))
except TimeoutError as exc:
raise HTTPException(504, "Plugin action timed out.") from exc
except ValueError as exc:
raise HTTPException(422, "Invalid plugin action input.") from exc
except Exception as exc:
logger.warning("Plugin action failed: %s/%s (%s)", namespace, action_name, type(exc).__name__)
raise HTTPException(502, "Plugin action failed.") from exc