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

399 lines
17 KiB
Python

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