deer-flow/backend/tests/test_plugin_action_authorization.py
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

395 lines
16 KiB
Python

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