mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* 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.
186 lines
6.4 KiB
Python
186 lines
6.4 KiB
Python
"""Composite plugin authorization targets: one encoder, one validator.
|
|
|
|
The three constructors are the only sanctioned producers of the strings a
|
|
plugin decision is keyed on, so these tests pin the charsets, the cross-kind
|
|
shape, and the drift guard that keeps the Python copies of the host's
|
|
namespace rule (authz, extension-api, plugin settings) from diverging.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from deerflow.authz.plugin_targets import (
|
|
MANAGEMENT_READ_PART,
|
|
MANAGEMENT_WRITE_PART,
|
|
PluginTargetError,
|
|
plugin_action_target,
|
|
plugin_management_target,
|
|
plugin_page_target,
|
|
)
|
|
|
|
# One sample set reused for every copy of the namespace rule, so a divergence
|
|
# between them fails here instead of at runtime.
|
|
VALID_NAMESPACES = [
|
|
"a",
|
|
"acme.reports",
|
|
"community.check",
|
|
"a1.b-c_d",
|
|
"a" * 96,
|
|
]
|
|
INVALID_NAMESPACES = [
|
|
"",
|
|
"A",
|
|
"Acme.Reports",
|
|
"1a",
|
|
".a",
|
|
"-a",
|
|
"_a",
|
|
"a/b",
|
|
"a b",
|
|
"a" * 97,
|
|
"aé",
|
|
]
|
|
|
|
VALID_ACTION_NAMES = ["reports", "ex-port", "a_1", "x" * 64]
|
|
INVALID_ACTION_NAMES = ["", "Reports", "1a", "a.b", "a b", "a" * 65]
|
|
|
|
VALID_SURFACE_IDS = ["reports", "my-page", "a1", "x" * 64]
|
|
INVALID_SURFACE_IDS = ["", "Reports", "1a", "my_page", "a.b", "a" * 65, "-page"]
|
|
|
|
|
|
# --- Composing -----------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("namespace", VALID_NAMESPACES)
|
|
def test_composes_namespace_scoped_targets(namespace: str):
|
|
assert plugin_action_target(namespace, "reports") == f"{namespace}/reports"
|
|
assert plugin_page_target(namespace, "reports") == f"{namespace}/reports"
|
|
assert plugin_management_target(namespace, MANAGEMENT_READ_PART) == f"{namespace}/permissions.read"
|
|
assert plugin_management_target(namespace, MANAGEMENT_WRITE_PART) == f"{namespace}/permissions.write"
|
|
|
|
|
|
def test_management_part_constants_are_the_documented_targets():
|
|
assert MANAGEMENT_READ_PART == "permissions.read"
|
|
assert MANAGEMENT_WRITE_PART == "permissions.write"
|
|
|
|
|
|
def test_read_and_write_are_distinct_targets_on_one_resource():
|
|
"""The built-in provider ignores ``action``, so the target must differ."""
|
|
assert plugin_management_target("acme.reports", MANAGEMENT_READ_PART) != plugin_management_target("acme.reports", MANAGEMENT_WRITE_PART)
|
|
|
|
|
|
def test_action_and_page_targets_are_byte_identical_for_one_declared_name():
|
|
"""Documented ambiguity: readers must key on ``(resource, target)``."""
|
|
assert plugin_action_target("acme.reports", "reports") == plugin_page_target("acme.reports", "reports")
|
|
|
|
|
|
# --- Rejecting -----------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("namespace", INVALID_NAMESPACES)
|
|
def test_every_constructor_rejects_an_invalid_namespace(namespace: str):
|
|
for build in (
|
|
lambda: plugin_action_target(namespace, "reports"),
|
|
lambda: plugin_page_target(namespace, "reports"),
|
|
lambda: plugin_management_target(namespace, MANAGEMENT_READ_PART),
|
|
):
|
|
with pytest.raises(PluginTargetError):
|
|
build()
|
|
|
|
|
|
@pytest.mark.parametrize("action_name", INVALID_ACTION_NAMES)
|
|
def test_action_constructor_rejects_an_invalid_action_name(action_name: str):
|
|
with pytest.raises(PluginTargetError):
|
|
plugin_action_target("acme.reports", action_name)
|
|
|
|
|
|
@pytest.mark.parametrize("surface_id", INVALID_SURFACE_IDS)
|
|
def test_page_constructor_rejects_an_invalid_surface_id(surface_id: str):
|
|
with pytest.raises(PluginTargetError):
|
|
plugin_page_target("acme.reports", surface_id)
|
|
|
|
|
|
@pytest.mark.parametrize("part", ["permissions", "read", "permissions.read ", "", "PERMISSIONS.READ", "permissions.write.extra", None, 1])
|
|
def test_management_constructor_accepts_only_the_two_documented_parts(part):
|
|
with pytest.raises(PluginTargetError):
|
|
plugin_management_target("acme.reports", part)
|
|
|
|
|
|
@pytest.mark.parametrize("namespace", VALID_NAMESPACES)
|
|
@pytest.mark.parametrize("action_name", VALID_ACTION_NAMES)
|
|
def test_valid_components_are_never_rejected(namespace: str, action_name: str):
|
|
assert plugin_action_target(namespace, action_name).startswith(f"{namespace}/")
|
|
|
|
|
|
def test_target_error_is_a_value_error():
|
|
"""Callers that only catch ``ValueError`` still fail loudly."""
|
|
assert issubclass(PluginTargetError, ValueError)
|
|
|
|
|
|
def test_action_charset_is_not_reused_for_surface_ids():
|
|
"""An underscore is legal in an action name and illegal in a surface id."""
|
|
assert plugin_action_target("acme.reports", "my_page").endswith("/my_page")
|
|
with pytest.raises(PluginTargetError):
|
|
plugin_page_target("acme.reports", "my_page")
|
|
|
|
|
|
def test_namespace_charset_is_not_reused_for_parts():
|
|
"""A dot is legal in a namespace and illegal in an action name."""
|
|
assert plugin_action_target("acme.reports", "reports").startswith("acme.reports/")
|
|
with pytest.raises(PluginTargetError):
|
|
plugin_action_target("acme.reports", "report.v2")
|
|
|
|
|
|
# --- Drift guard ---------------------------------------------------------------
|
|
|
|
|
|
def _accepted_by_plugin_targets(namespace: str) -> bool:
|
|
try:
|
|
plugin_action_target(namespace, "reports")
|
|
except PluginTargetError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _accepted_by_extension_api(namespace: str) -> bool:
|
|
from deerflow_extension_api.auth import _require_plugin_namespace
|
|
|
|
try:
|
|
_require_plugin_namespace(namespace)
|
|
except ValueError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _accepted_by_plugin_settings(namespace: str) -> bool:
|
|
from deerflow_extension_api.settings import SettingsContribution, SettingsField
|
|
|
|
from deerflow.config.plugin_settings import validate_contribution
|
|
|
|
contribution = SettingsContribution(
|
|
namespace=namespace,
|
|
title="Drift probe",
|
|
fields=(SettingsField("enabled", "Enabled", "boolean", True),),
|
|
)
|
|
try:
|
|
validate_contribution(contribution)
|
|
except ValueError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _accepted_by_tool_provenance(namespace: str) -> bool:
|
|
from deerflow.tools import tool_provenance
|
|
|
|
return tool_provenance._NAMESPACE_RE.fullmatch(namespace) is not None
|
|
|
|
|
|
@pytest.mark.parametrize("namespace", [*VALID_NAMESPACES, *INVALID_NAMESPACES])
|
|
def test_every_namespace_rule_copy_agrees(namespace: str):
|
|
expected = namespace in VALID_NAMESPACES
|
|
assert _accepted_by_plugin_targets(namespace) is expected
|
|
assert _accepted_by_extension_api(namespace) is expected
|
|
assert _accepted_by_plugin_settings(namespace) is expected
|
|
assert _accepted_by_tool_provenance(namespace) is expected
|