From 82671596f36ed8ce7e75f138d048d323e9912f2f Mon Sep 17 00:00:00 2001 From: Zeren Wang <53075619+Vanzeren@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:46:12 +0200 Subject: [PATCH] feat(authz): add plugin resource targets, provenance, action and management enforcement (#5842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- backend/app/gateway/app.py | 86 ++ backend/app/gateway/authz.py | 244 ++++- backend/app/gateway/routers/plugins.py | 7 + .../deerflow_extension_api/__init__.py | 10 +- .../deerflow_extension_api/auth.py | 89 ++ backend/packages/extension-api/pyproject.toml | 2 +- .../deerflow/agents/assembly_descriptor.py | 23 +- .../harness/deerflow/authz/__init__.py | 28 + .../harness/deerflow/authz/plugin_authz.py | 398 ++++++++ .../harness/deerflow/authz/plugin_targets.py | 77 ++ .../harness/deerflow/authz/provider.py | 8 + .../packages/harness/deerflow/authz/rbac.py | 9 + .../harness/deerflow/config/app_config.py | 17 + .../deerflow/extensions/plugin_tools.py | 5 +- .../harness/deerflow/tools/tool_provenance.py | 218 +++++ backend/packages/harness/pyproject.toml | 2 +- .../blocking_io/test_plugin_authorization.py | 186 ++++ backend/tests/test_app_config_reload.py | 35 +- backend/tests/test_extension_api_contracts.py | 2 +- .../tests/test_plugin_action_authorization.py | 394 ++++++++ backend/tests/test_plugin_management_guard.py | 894 ++++++++++++++++++ backend/tests/test_plugin_targets.py | 185 ++++ backend/tests/test_plugin_tools.py | 19 + .../tests/test_rbac_authorization_provider.py | 35 + backend/tests/test_tool_provenance.py | 204 ++++ backend/uv.lock | 2 +- config.example.yaml | 18 +- deploy/helm/deer-flow/README.md | 2 +- deploy/helm/deer-flow/values.yaml | 2 +- docs/full-stack-plugins.md | 113 ++- ...able-authorization-implementation-notes.md | 215 +++++ .../en/harness/checkpoints/reference.mdx | 2 +- .../content/en/harness/extensions/index.mdx | 2 +- .../en/harness/extensions/reference.mdx | 31 +- .../zh/harness/checkpoints/reference.mdx | 2 +- .../content/zh/harness/extensions/index.mdx | 2 +- .../zh/harness/extensions/reference.mdx | 31 +- 37 files changed, 3558 insertions(+), 41 deletions(-) create mode 100644 backend/packages/harness/deerflow/authz/plugin_authz.py create mode 100644 backend/packages/harness/deerflow/authz/plugin_targets.py create mode 100644 backend/packages/harness/deerflow/tools/tool_provenance.py create mode 100644 backend/tests/blocking_io/test_plugin_authorization.py create mode 100644 backend/tests/test_plugin_action_authorization.py create mode 100644 backend/tests/test_plugin_management_guard.py create mode 100644 backend/tests/test_plugin_targets.py create mode 100644 backend/tests/test_tool_provenance.py diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 3eb29c34d1..65d85a772b 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -4,6 +4,8 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager, suppress from deerflow_extension_api import ( + EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY, + EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY, EXTENSION_PRINCIPAL_RESOLVER_KEY, RUN_EVIDENCE_READER_RESOLVER_KEY, ExtensionPrincipal, @@ -83,6 +85,84 @@ _SHUTDOWN_HOOK_TIMEOUT_SECONDS = 5.0 _RETRIEVAL_WARM_SHUTDOWN_TIMEOUT_SECONDS = 1.0 +def _installed_plugin_namespace(request: Request, namespace: str) -> bool: + """True when *namespace* is a plugin installed for this app instance.""" + state = getattr(getattr(request, "app", None), "state", None) + loaded = getattr(state, "extensions", None) + for _, plugin in getattr(loaded, "plugins", ()): + if plugin.namespace == namespace: + return True + return False + + +def _resolve_extension_plugin_management(request: Request, namespace: str, scope: str = "read") -> bool | None: + """Answer a contributed route's ``plugin_management`` question (sync callers). + + ``None`` means the host cannot answer — an unknown plugin or an anonymous + caller — and the public helper turns that into a denial. ``True``/``False`` + are decisions: ``True`` when authorization is disabled, so a deployment that + turns authorization off does not start 403-ing enterprise routes, and + ``False`` for a policy denial or a configuration that cannot be read (that + resolution layer is fail-closed). + + Runs in the caller's thread: a FastAPI ``def`` endpoint is executed in the + thread pool, which is where a synchronous caller legitimately lives. An + async endpoint must use :func:`_resolve_extension_plugin_management_async`. + """ + from app.gateway.authz import _PluginAuthorizationUnavailable, resolve_plugin_authorization + from deerflow.authz.plugin_authz import PluginAuthorizationError, enforce_plugin_management + + if not _installed_plugin_namespace(request, namespace): + return None + try: + provider, principal, app_config = resolve_plugin_authorization(request) + except _PluginAuthorizationUnavailable as unavailable: + return not unavailable.fail_closed + if provider is None: + return True + if principal is None: + return None + try: + enforce_plugin_management( + principal=principal, + app_config=app_config, + namespace=namespace, + write=scope == "write", + provider=provider, + ) + except PluginAuthorizationError: + return False + return True + + +async def _resolve_extension_plugin_management_async(request: Request, namespace: str, scope: str = "read") -> bool | None: + """Async counterpart of :func:`_resolve_extension_plugin_management`.""" + from app.gateway.authz import _PluginAuthorizationUnavailable, aresolve_plugin_authorization + from deerflow.authz.plugin_authz import PluginAuthorizationError, aenforce_plugin_management + + if not _installed_plugin_namespace(request, namespace): + return None + try: + provider, principal, app_config = await aresolve_plugin_authorization(request) + except _PluginAuthorizationUnavailable as unavailable: + return not unavailable.fail_closed + if provider is None: + return True + if principal is None: + return None + try: + await aenforce_plugin_management( + principal=principal, + app_config=app_config, + namespace=namespace, + write=scope == "write", + provider=provider, + ) + except PluginAuthorizationError: + return False + return True + + async def _ensure_admin_user(app: FastAPI) -> None: """Startup hook: handle first boot and migrate orphan threads otherwise. @@ -844,6 +924,12 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, _resolve_extension_principal) + # Contributed management routes ask the same provider the tool path uses, + # through a neutral ``bool | None`` answer. The host owns plugin-namespace + # validation and the request-scoped provider; the handler only asks. + setattr(app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY, _resolve_extension_plugin_management) + setattr(app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY, _resolve_extension_plugin_management_async) + def _resolve_extension_run_evidence_reader(request): """Bind evidence access to the principal stamped by AuthMiddleware.""" principal = _resolve_extension_principal(request) diff --git a/backend/app/gateway/authz.py b/backend/app/gateway/authz.py index 273c2784da..466b67a7d7 100644 --- a/backend/app/gateway/authz.py +++ b/backend/app/gateway/authz.py @@ -47,7 +47,7 @@ from fastapi import HTTPException, Request from deerflow.authz.principal import build_principal_from_context from deerflow.authz.provider import AuthorizationProvider, AuthzDecision, AuthzRequest, Principal -from deerflow.authz.runtime import resolve_authorization_provider +from deerflow.authz.runtime import construct_authorization_provider, resolve_authorization_provider, resolve_authorization_provider_spec from deerflow.config.authorization_config import AuthorizationConfig if TYPE_CHECKING: @@ -487,6 +487,248 @@ def authorize_sandbox_for_request( raise SandboxAuthorizationError(role=context.get("user_role")) from None +# --- Plugin resource authorization ------------------------------------------- +# +# The plugin request paths deliberately do NOT reuse the route provider cache +# above: that cache is synchronous, constructs inline on the calling thread and +# keys on config identity alone, so a hit cannot prove the instance belongs to +# the caller's event loop. A plugin provider may be loop-affine (its +# ``__init__`` may create an asyncio client), so this cache keys on +# ``(config signature, loop key)`` and never hands a loop-built instance to a +# sync caller or to a different loop. + +_PLUGIN_PROVIDER_SYNC_SLOT = object() +#: loop_key → (config signature, provider, owning loop or None for the sync slot). +_plugin_provider_cache: dict[object, tuple[str, AuthorizationProvider, asyncio.AbstractEventLoop | None]] = {} + + +class _PluginAuthorizationUnavailable(Exception): + """Raised when the plugin provider cannot be resolved for a request. + + Carries ``fail_closed`` so callers choose between deny and legacy allow + without re-reading config (mirrors ``_AuthorizationUnavailable``). + """ + + def __init__(self, *, fail_closed: bool) -> None: + self.fail_closed = fail_closed + + +def _plugin_loaded_config() -> AppConfig | None: + """The configuration this host is running on, or ``None`` if it has none. + + ``get_app_config()`` re-reads the file on every request (hot reload), so a + failed read needs the last successfully loaded value to tell a host that has + no configuration at all from one whose running policy just became + unreadable. + """ + from deerflow.config.app_config import peek_loaded_app_config + + return peek_loaded_app_config() + + +def _plugin_config_failure_fail_closed() -> bool: + """Failure flag for a plugin decision whose configuration cannot be read. + + * No loaded config: the host never ran on a configuration, and the + ``config.yaml``-less case returned no gate before reaching this call, so + this is a file that exists but cannot be read or validated right now. The + flag that would permit an allow is unreadable, so it fails closed + (review P1, round 1). + * A loaded config with authorization disabled: there is no gate to apply. + * A loaded config with authorization enabled: follow that policy's own + ``fail_closed`` (``True`` by default), so a host that lost the config it + is running on answers like any other authorization failure instead of + reading the loss as "disabled" (review P1, round 2). + """ + loaded = _plugin_loaded_config() + if loaded is None: + return True + authz_config = getattr(loaded, "authorization", None) + if getattr(authz_config, "enabled", None) is not True: + return False + return getattr(authz_config, "fail_closed", False) is True + + +def _plugin_app_config() -> AppConfig | None: + """Read the config snapshot for a plugin decision. + + ``None`` means this host has no configuration file *and* has never loaded + one, so there is no policy to apply — the same rule the route-scoped gates + use for environments without a ``config.yaml`` (CI runners, direct-call + tests, a host that mounts only the plugins router): authorization can only + be enabled through config. + + A host running on a configuration never reads a failed load as "disabled". + Both the lost file (a hot reload, an atomic replace, a ConfigMap remount) + and a file that exists but cannot be read or validated right now propagate, + so the caller applies the failure policy of the policy it is running on + (:func:`_plugin_config_failure_fail_closed`). + """ + from deerflow.config.app_config import get_app_config + + try: + return get_app_config() + except FileNotFoundError: + if _plugin_loaded_config() is None: + logger.debug("No `config.yaml` and no loaded config; the plugin authorization gate is a no-op", exc_info=True) + return None + raise + + +async def _plugin_app_config_async() -> AppConfig | None: + """Off-loop :func:`_plugin_app_config` for async request paths.""" + return await asyncio.to_thread(_plugin_app_config) + + +def _plugin_config_signature(config: AuthorizationConfig) -> str: + return repr(sorted(config.model_dump().items())) + + +def _store_plugin_provider(loop_key: object, loop: asyncio.AbstractEventLoop | None, signature: str, provider: AuthorizationProvider) -> None: + """Publish one entry, dropping entries whose loop has closed (bounded growth).""" + for key, entry in list(_plugin_provider_cache.items()): + owner = entry[2] + if owner is not None and owner.is_closed(): + _plugin_provider_cache.pop(key, None) + _plugin_provider_cache[loop_key] = (signature, provider, loop) + + +def _get_cached_plugin_provider_sync(config: AuthorizationConfig) -> AuthorizationProvider: + """Resolve the provider for sync callers (and FastAPI ``def`` thread-pool workers).""" + signature = _plugin_config_signature(config) + cached = _plugin_provider_cache.get(_PLUGIN_PROVIDER_SYNC_SLOT) + if cached is not None and cached[0] == signature: + return cached[1] + provider = resolve_authorization_provider(config) + if provider is None: + raise ValueError("authorization is enabled but provider resolution returned None") + _store_plugin_provider(_PLUGIN_PROVIDER_SYNC_SLOT, None, signature, provider) + return provider + + +async def _aget_cached_plugin_provider(config: AuthorizationConfig) -> AuthorizationProvider: + """Resolve the provider belonging to *this* loop; a miss discovers off-loop.""" + loop = asyncio.get_running_loop() + loop_key = id(loop) + signature = _plugin_config_signature(config) + cached = _plugin_provider_cache.get(loop_key) + if cached is not None and cached[0] == signature: + return cached[1] + + if getattr(config, "provider", None) is None: + # Nothing to import; preserve the resolver's own "enabled but unconfigured" error. + provider = resolve_authorization_provider(config) + else: + spec = await asyncio.to_thread(resolve_authorization_provider_spec, config) + # Construction stays on the calling loop: async providers may create + # loop-affine clients in ``__init__``. + provider = construct_authorization_provider(spec, config) + if provider is None: + raise ValueError("authorization is enabled but provider resolution returned None") + _store_plugin_provider(loop_key, loop, signature, provider) + return provider + + +def _plugin_request_principal(request: Request, authz_config: AuthorizationConfig) -> Principal | None: + """Build the request-scoped Principal, or ``None`` when the caller is anonymous.""" + user = getattr(getattr(request, "state", None), "user", None) + if user is None: + return None + return build_principal_from_context( + _route_authz_context(user, is_internal=_is_internal_caller(request, user)), + default_role=authz_config.default_role, + ) + + +def resolve_plugin_authorization(request: Request) -> tuple[AuthorizationProvider | None, Principal | None, AppConfig | None]: + """Return ``(provider, principal, app_config)`` for plugin resources, for sync callers. + + ``provider is None`` means authorization is disabled for this host — the + caller keeps today's behavior; ``app_config is None`` means there is no + configuration at all. The same ``app_config`` snapshot that produced the + provider is returned so the enforcement layer never re-reads the + configuration. Raises ``_PluginAuthorizationUnavailable`` (carrying + ``fail_closed``) when the configuration this host is running on became + unreadable, or when the provider cannot be resolved. + """ + try: + app_config = _plugin_app_config() + except Exception: + logger.warning("App config unavailable for plugin authorization", exc_info=True) + raise _PluginAuthorizationUnavailable(fail_closed=_plugin_config_failure_fail_closed()) from None + if app_config is None: + return None, None, None + authz_config = getattr(app_config, "authorization", None) + if getattr(authz_config, "enabled", None) is not True: + return None, None, app_config + try: + provider = _get_cached_plugin_provider_sync(authz_config) + except Exception: + logger.warning("Failed to resolve authorization provider for plugin resources", exc_info=True) + raise _PluginAuthorizationUnavailable(fail_closed=getattr(authz_config, "fail_closed", False) is True) from None + return provider, _plugin_request_principal(request, authz_config), app_config + + +async def aresolve_plugin_authorization(request: Request) -> tuple[AuthorizationProvider | None, Principal | None, AppConfig | None]: + """Async ``(provider, principal, app_config)`` for plugin resources. + + Never returns the sync slot, and never performs synchronous config loading, + provider discovery or provider calls on the event loop. The returned + ``app_config`` is the snapshot the provider was resolved from, so the + enforcement layer never re-reads the configuration. + """ + try: + app_config = await _plugin_app_config_async() + except Exception: + logger.warning("App config unavailable for plugin authorization", exc_info=True) + raise _PluginAuthorizationUnavailable(fail_closed=_plugin_config_failure_fail_closed()) from None + if app_config is None: + return None, None, None + authz_config = getattr(app_config, "authorization", None) + if getattr(authz_config, "enabled", None) is not True: + return None, None, app_config + try: + provider = await _aget_cached_plugin_provider(authz_config) + except Exception: + logger.warning("Failed to resolve authorization provider for plugin resources", exc_info=True) + raise _PluginAuthorizationUnavailable(fail_closed=getattr(authz_config, "fail_closed", False) is True) from None + return provider, _plugin_request_principal(request, authz_config), app_config + + +async def authorize_plugin_action_for_request(request: Request, *, namespace: str, action_name: str) -> None: + """Authorize a registered plugin action before its handler runs (O2). + + Returns normally when the action is permitted (or authorization is + disabled); raises ``HTTPException(403)`` on deny, and on a provider + resolution failure under ``fail_closed``. + """ + from deerflow.authz.plugin_authz import PluginAuthorizationError, aenforce_plugin_action + + try: + provider, principal, app_config = await aresolve_plugin_authorization(request) + except _PluginAuthorizationUnavailable as unavailable: + if unavailable.fail_closed: + raise _plugin_action_denied() from None + return + if provider is None: + # Authorization is disabled: today's behavior. + return + try: + await aenforce_plugin_action( + principal=principal, + app_config=app_config, + namespace=namespace, + action_name=action_name, + provider=provider, + ) + except PluginAuthorizationError as error: + raise _plugin_action_denied() from error + + +def _plugin_action_denied() -> HTTPException: + return HTTPException(status_code=403, detail="Plugin action not permitted for your role.") + + @dataclass(slots=True) class SandboxRequestLease: """One Gateway request's process-local use of a sandbox client.""" diff --git a/backend/app/gateway/routers/plugins.py b/backend/app/gateway/routers/plugins.py index bd793b2dc8..2e63b0c723 100644 --- a/backend/app/gateway/routers/plugins.py +++ b/backend/app/gateway/routers/plugins.py @@ -116,6 +116,13 @@ async def invoke_plugin_action(request: Request, namespace: str, action_name: st 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) diff --git a/backend/packages/extension-api/deerflow_extension_api/__init__.py b/backend/packages/extension-api/deerflow_extension_api/__init__.py index af92d6c9eb..db05469f6c 100644 --- a/backend/packages/extension-api/deerflow_extension_api/__init__.py +++ b/backend/packages/extension-api/deerflow_extension_api/__init__.py @@ -14,9 +14,13 @@ from deerflow_extension_api.assembly import ( ToolDescriptor, ) from deerflow_extension_api.auth import ( + EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY, + EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY, EXTENSION_PRINCIPAL_RESOLVER_KEY, ExtensionPrincipal, + arequire_plugin_management, require_admin, + require_plugin_management, resolve_principal, ) from deerflow_extension_api.compaction import ( @@ -82,7 +86,7 @@ from deerflow_extension_api.state import ExtensionData #: Contract version. Before 1.0, minors may break and patches are additive. #: From 1.0 on, bump the major for breaking changes. -API_VERSION = "0.2.3" +API_VERSION = "0.2.4" __all__ = [ "ActionContext", @@ -94,6 +98,8 @@ __all__ = [ "ToolContext", "SettingsField", "API_VERSION", + "EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY", + "EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY", "EXTENSION_PRINCIPAL_RESOLVER_KEY", "EXTENSION_TASK_STORE_KEY", "MESSAGE_CONTENT_KIND_KEY", @@ -140,10 +146,12 @@ __all__ = [ "canonical_hash", "canonical_json", "collect_release_policies", + "arequire_plugin_management", "extension", "provenance_kwargs", "read_provenance", "require_admin", + "require_plugin_management", "resolve_principal", "task_store_from_runtime", ] diff --git a/backend/packages/extension-api/deerflow_extension_api/auth.py b/backend/packages/extension-api/deerflow_extension_api/auth.py index dda87fbb2a..bdd12bbac1 100644 --- a/backend/packages/extension-api/deerflow_extension_api/auth.py +++ b/backend/packages/extension-api/deerflow_extension_api/auth.py @@ -11,11 +11,23 @@ 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) @@ -51,3 +63,80 @@ def require_admin(request: object) -> ExtensionPrincipal: 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 diff --git a/backend/packages/extension-api/pyproject.toml b/backend/packages/extension-api/pyproject.toml index b3f045595e..8b2c231e5e 100644 --- a/backend/packages/extension-api/pyproject.toml +++ b/backend/packages/extension-api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "deerflow-extension-api" -version = "0.2.3" +version = "0.2.4" description = "Public contracts for DeerFlow extensions" requires-python = ">=3.12" # Keep the contract package import-light and independent from the host. Public diff --git a/backend/packages/harness/deerflow/agents/assembly_descriptor.py b/backend/packages/harness/deerflow/agents/assembly_descriptor.py index a3a1cde079..504d8d80f5 100644 --- a/backend/packages/harness/deerflow/agents/assembly_descriptor.py +++ b/backend/packages/harness/deerflow/agents/assembly_descriptor.py @@ -34,6 +34,7 @@ from deerflow_extension_api import ( from deerflow.sandbox.env_policy import is_blocked_env_name from deerflow.tools.mcp_metadata import get_mcp_source, is_mcp_tool +from deerflow.tools.tool_provenance import resolve_tool_provenance logger = logging.getLogger(__name__) @@ -211,21 +212,13 @@ def _tool_schema(tool: object) -> dict[str, object]: def _tool_source(tool: object) -> str: - if is_mcp_tool(tool): - source = get_mcp_source(tool) - return f"mcp:{source['server_name']}" if source is not None else "mcp:unknown" - metadata = getattr(tool, "metadata", None) - if isinstance(metadata, dict): - declared = metadata.get("deerflow_tool_source") - if isinstance(declared, str) and declared: - return declared - callable_object = getattr(tool, "func", None) or getattr(tool, "coroutine", None) - module = getattr(callable_object, "__module__", "") or "" - if module.startswith("deerflow.tools.builtins") or module.startswith("deerflow.agents.memory"): - return "builtin" - if "skill" in module: - return "skill" - return "community" if module else "builtin" + """Project the tool's display attribution (see `deerflow.tools.tool_provenance`). + + Delegates so the descriptor identity and the execution-time label cannot + disagree. Labels for sources that carry no host-written tag are unchanged. + """ + provenance = resolve_tool_provenance(tool) + return provenance.source if provenance is not None else "builtin" def describe_tool(tool: object) -> ToolDescriptor: diff --git a/backend/packages/harness/deerflow/authz/__init__.py b/backend/packages/harness/deerflow/authz/__init__.py index 51df7ea0cc..19d494ec31 100644 --- a/backend/packages/harness/deerflow/authz/__init__.py +++ b/backend/packages/harness/deerflow/authz/__init__.py @@ -2,6 +2,22 @@ from deerflow.authz.adapter import GuardrailAuthorizationAdapter from deerflow.authz.enforcement import filter_tools_by_authorization +from deerflow.authz.plugin_authz import ( + PluginAuthorizationError, + aenforce_plugin_action, + aenforce_plugin_management, + afilter_plugin_management, + afilter_plugin_pages, + enforce_plugin_action, + enforce_plugin_management, +) +from deerflow.authz.plugin_targets import ( + MANAGEMENT_READ_PART, + MANAGEMENT_WRITE_PART, + plugin_action_target, + plugin_management_target, + plugin_page_target, +) from deerflow.authz.principal import build_principal_from_context, normalize_authz_attributes from deerflow.authz.provider import AuthorizationProvider, AuthzDecision, AuthzReason, AuthzRequest, Principal from deerflow.authz.rbac import RbacAuthorizationProvider @@ -15,12 +31,24 @@ __all__ = [ "AuthzRequest", "AuthorizationProvider", "GuardrailAuthorizationAdapter", + "MANAGEMENT_READ_PART", + "MANAGEMENT_WRITE_PART", + "PluginAuthorizationError", "Principal", "RbacAuthorizationProvider", + "aenforce_plugin_action", + "aenforce_plugin_management", + "afilter_plugin_management", + "afilter_plugin_pages", "apply_tool_authorization", "authorize_sandbox_execution", "build_principal_from_context", + "enforce_plugin_action", + "enforce_plugin_management", "filter_tools_by_authorization", "normalize_authz_attributes", + "plugin_action_target", + "plugin_management_target", + "plugin_page_target", "resolve_authorization_provider", ] diff --git a/backend/packages/harness/deerflow/authz/plugin_authz.py b/backend/packages/harness/deerflow/authz/plugin_authz.py new file mode 100644 index 0000000000..de80003578 --- /dev/null +++ b/backend/packages/harness/deerflow/authz/plugin_authz.py @@ -0,0 +1,398 @@ +"""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, + ) diff --git a/backend/packages/harness/deerflow/authz/plugin_targets.py b/backend/packages/harness/deerflow/authz/plugin_targets.py new file mode 100644 index 0000000000..e7d56096e2 --- /dev/null +++ b/backend/packages/harness/deerflow/authz/plugin_targets.py @@ -0,0 +1,77 @@ +"""Canonical encoding for composite plugin authorization targets. + +One module owns every ``target`` string a plugin decision is asked about, so the +producers (the registered-action dispatcher, the page projection, the management +guards, the resource catalog) and the readers (providers, enterprise policy) +cannot disagree about encoding or validation. + +A target is ``"{namespace}/{part}"``: the left side is always a host-validated +plugin namespace, the right side is the kind-specific component validated +against the charset of the layer that already owns it. The namespace charset +excludes ``/``, so the composite is unambiguous, and **no call site may build a +target by string concatenation** — every producer calls one of the three +constructors below, each of which raises :class:`PluginTargetError` instead of +returning a best-effort string. + +Actions are carried for protocol fidelity only: the built-in RBAC provider +ignores ``AuthzRequest.action``, so read/write authority is separated by +*different targets*, never by different actions on one policy. +""" + +from __future__ import annotations + +import re +from typing import Literal + +# Mirrors deerflow.config.plugin_settings (host registration rule) byte for byte. +_NAMESPACE_PATTERN = r"[a-z][a-z0-9_.-]{0,95}" +# Mirrors deerflow.extensions.registry's registered-backend-action rule. +_ACTION_PATTERN = r"[a-z][a-z0-9_-]{0,63}" +# Mirrors the browser module's surface-id rule (frontend/src/core/extensions/registry.ts). +_SURFACE_PATTERN = r"[a-z][a-z0-9-]{0,63}" + +MANAGEMENT_READ_PART = "permissions.read" +MANAGEMENT_WRITE_PART = "permissions.write" + +#: The only accepted management parts. Read and write are different targets so +#: the built-in provider can distinguish them (it ignores ``action``). +ManagementPart = Literal["permissions.read", "permissions.write"] + +_NAMESPACE_RE = re.compile(_NAMESPACE_PATTERN) +_ACTION_RE = re.compile(_ACTION_PATTERN) +_SURFACE_RE = re.compile(_SURFACE_PATTERN) +_MANAGEMENT_RE = re.compile(f"(?:{re.escape(MANAGEMENT_READ_PART)}|{re.escape(MANAGEMENT_WRITE_PART)})") + + +class PluginTargetError(ValueError): + """A plugin resource target could not be composed from host-validated components.""" + + +def _join(namespace: object, part: object, *, field: str, pattern: re.Pattern[str]) -> str: + """Compose ``namespace/part`` after validating both halves, or raise.""" + if not isinstance(namespace, str) or not _NAMESPACE_RE.fullmatch(namespace): + raise PluginTargetError(f"invalid plugin namespace {namespace!r}; expected {_NAMESPACE_PATTERN}") + if not isinstance(part, str) or not pattern.fullmatch(part): + raise PluginTargetError(f"invalid plugin {field} {part!r}; expected {pattern.pattern}") + return f"{namespace}/{part}" + + +def plugin_action_target(namespace: str, action_name: str) -> str: + """Target for a registered plugin backend action (``resource="plugin_action"``).""" + return _join(namespace, action_name, field="action name", pattern=_ACTION_RE) + + +def plugin_page_target(namespace: str, surface_id: str) -> str: + """Target for a declared plugin page (``resource="plugin_page"``). + + ``plugin_action_target(ns, "reports")`` and ``plugin_page_target(ns, "reports")`` + are deliberately byte-identical: the resource differs, the target does not. + Readers therefore look resources up by ``(resource, target)``, never by + target alone. + """ + return _join(namespace, surface_id, field="surface id", pattern=_SURFACE_RE) + + +def plugin_management_target(namespace: str, part: ManagementPart) -> str: + """Target for a management read/write check (``resource="plugin_management"``).""" + return _join(namespace, part, field="management part", pattern=_MANAGEMENT_RE) diff --git a/backend/packages/harness/deerflow/authz/provider.py b/backend/packages/harness/deerflow/authz/provider.py index 3f5e31d136..8fffab4b25 100644 --- a/backend/packages/harness/deerflow/authz/provider.py +++ b/backend/packages/harness/deerflow/authz/provider.py @@ -119,5 +119,13 @@ class AuthorizationProvider(Protocol): role→resource map should delegate to :meth:`authorize` per item and return only the allowed subset. Providers with a static map can override this for O(1) filtering and fail-closed visibility. + + The answer can only narrow *candidates*: every caller intersects it with + the list it asked about, so naming a target that was never a candidate + grants nothing. + + Implementations must be thread-safe: an async caller offloads this + synchronous method to a worker thread, so it may run while the event + loop keeps serving other work. """ ... diff --git a/backend/packages/harness/deerflow/authz/rbac.py b/backend/packages/harness/deerflow/authz/rbac.py index 678a689e30..1b1969f2de 100644 --- a/backend/packages/harness/deerflow/authz/rbac.py +++ b/backend/packages/harness/deerflow/authz/rbac.py @@ -30,6 +30,15 @@ _RESOURCE_POLICY_KEYS: dict[str, str] = { "sandbox": "sandbox", "mcp_server": "mcp_servers", "route": "routes", + # Plugin resources. The left side is the request ``resource`` and the right + # side is the ``config.yaml`` key. ``plugin_management`` is genuinely + # self-mapped (like ``sandbox``), so a config key of the same name is legal; + # ``plugin_action`` is not, and using it as a config key is rejected at + # construction. A resource with no policy key is unrestricted, which is the + # documented non-breaking path for deployments that enable authorization + # without listing these keys. + "plugin_action": "plugin_actions", + "plugin_management": "plugin_management", } _ALL = object() # sentinel meaning "allow all candidates" diff --git a/backend/packages/harness/deerflow/config/app_config.py b/backend/packages/harness/deerflow/config/app_config.py index 2bff5cdba7..7f3ef3ecca 100644 --- a/backend/packages/harness/deerflow/config/app_config.py +++ b/backend/packages/harness/deerflow/config/app_config.py @@ -783,6 +783,23 @@ def peek_current_app_config() -> AppConfig | None: return _current_app_config.get() +def peek_loaded_app_config() -> AppConfig | None: + """Return the configuration this process has loaded, without touching the filesystem. + + The runtime-scoped override wins when one is active; otherwise this is the + cached singleton ``get_app_config()`` last loaded (or ``set_app_config()`` + installed). ``None`` means no configuration has ever been loaded in this + process, which is what a host without a ``config.yaml`` looks like — as + opposed to a host that *is* running on a config and can no longer read the + file, where callers need the loaded value to tell "unavailable" from + "never configured". + """ + override = _current_app_config.get() + if override is not None: + return override + return _app_config + + def push_current_app_config(config: AppConfig) -> None: """Push a runtime-scoped AppConfig override for the current execution context.""" stack = _current_app_config_stack.get() diff --git a/backend/packages/harness/deerflow/extensions/plugin_tools.py b/backend/packages/harness/deerflow/extensions/plugin_tools.py index 9b814f9b7f..16727a71be 100644 --- a/backend/packages/harness/deerflow/extensions/plugin_tools.py +++ b/backend/packages/harness/deerflow/extensions/plugin_tools.py @@ -19,6 +19,7 @@ from langchain_core.tools import StructuredTool, ToolException from deerflow.config.plugin_settings import defaults from deerflow.runtime.user_context import resolve_runtime_user_id +from deerflow.tools.tool_provenance import tag_plugin_tool logger = logging.getLogger(__name__) @@ -87,7 +88,9 @@ def _build_tool(source, plugin, declaration): logger.warning("Plugin tool failed: %s/%s (%s)", plugin.namespace, declaration.name, type(exc).__name__) raise ToolException("Plugin tool unavailable or input rejected.") from None - return StructuredTool(name=plugin_tool_name(plugin.namespace, declaration.name), description=declaration.description, args_schema=schema, coroutine=invoke, handle_tool_error=True) + tool = StructuredTool(name=plugin_tool_name(plugin.namespace, declaration.name), description=declaration.description, args_schema=schema, coroutine=invoke, handle_tool_error=True) + tag_plugin_tool(tool, namespace=plugin.namespace, declaration=declaration.name, installation=source) + return tool def build_plugin_tools(loaded, *, groups=None, reserved_names=()): diff --git a/backend/packages/harness/deerflow/tools/tool_provenance.py b/backend/packages/harness/deerflow/tools/tool_provenance.py new file mode 100644 index 0000000000..f6245bc6ef --- /dev/null +++ b/backend/packages/harness/deerflow/tools/tool_provenance.py @@ -0,0 +1,218 @@ +"""Host-recorded tool provenance. + +Two consumers with different trust needs share this module: + +* **Display attribution** — which component a tool came from, for assembly + descriptors, traces and enterprise context. Best effort and consistency + checked; a provenance label is **never** a trust input. +* **Exemption identity** — the one host decision that needs certainty (the + Layer-2 infrastructure-tool exemption) compares the concrete bound tool + object, not a label. That reference travels on + ``GuardrailRequest.tool_identity``; this module decides nothing. + +Only two sources write a tag, both host-written at construction time: MCP tools +(:mod:`deerflow.tools.mcp_metadata`, written by the loader) and plugin tools +(:func:`tag_plugin_tool`, written by ``deerflow.extensions.plugin_tools``). +Every other source keeps a heuristic label computed here, and the resolution +order is part of the contract: + +``mcp flag`` → ``plugin tag`` → declared ``deerflow_tool_source`` → module +heuristic → ``builtin`` when the callable exposes no module. + +Some host-owned sources are labeled ``community`` by that heuristic +(task-continuity tools, project-shelf tools, config- and middleware-declared +tools); those labels are display-only and unchanged. +""" + +from __future__ import annotations + +import logging +import re +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from deerflow.tools.mcp_metadata import get_mcp_source, is_mcp_tool + +logger = logging.getLogger(__name__) + +PLUGIN_TOOL_METADATA_KEY = "deerflow_plugin" +PLUGIN_TOOL_SOURCE_METADATA_KEY = "deerflow_plugin_source" +DECLARED_TOOL_SOURCE_METADATA_KEY = "deerflow_tool_source" + +# Mirrors the host plugin-namespace registration rule +# (deerflow.config.plugin_settings). Independent copy on purpose: this module is +# a leaf that must not import the extension registry, and +# tests/test_plugin_targets.py asserts all three copies agree on one sample set. +_NAMESPACE_PATTERN = r"[a-z][a-z0-9_.-]{0,95}" +_NAMESPACE_RE = re.compile(_NAMESPACE_PATTERN) + +# Order of the optional fields in the plain-mapping form handed to enterprise +# consumers (GuardrailRequest.tool_provenance, AuthzRequest.context). +_OPTIONAL_CONTEXT_FIELDS = ("namespace", "declaration", "installation", "operation", "mcp_server", "mcp_transport") + + +@dataclass(frozen=True, slots=True) +class ToolProvenance: + """Where a bound tool came from, for display and enterprise context.""" + + source: str + """``"plugin:"`` | ``"mcp:"`` | ``"builtin"`` | ``"skill"`` | ``"community"``.""" + + namespace: str | None = None + declaration: str | None = None + """The plugin's raw ``ModelTool.name`` (not the generated bound tool name).""" + + installation: str | None = None + """The contributing ``ExtensionSpec`` source string.""" + + operation: str | None = None + """The plugin's declared shared operation, when it declared one.""" + + mcp_server: str | None = None + mcp_transport: str | None = None + + +def tag_plugin_tool( + tool: object, + *, + namespace: str, + declaration: str, + installation: str, + operation: str | None = None, +) -> None: + """Record host-side plugin provenance on ``tool`` (mutates in place). + + Only the host calls this, at plugin-tool construction, with values the + registry already validated. ``ModelTool`` carries no metadata field, so a + plugin cannot inject a ``deerflow_*`` tag through its declaration. + """ + source: dict[str, Any] = { + "namespace": namespace, + "declaration": declaration, + "installation": installation, + } + if operation is not None: + source["operation"] = operation + tool.metadata = { + **(getattr(tool, "metadata", None) or {}), + PLUGIN_TOOL_METADATA_KEY: True, + PLUGIN_TOOL_SOURCE_METADATA_KEY: source, + } + + +def is_plugin_tool(tool: object) -> bool: + """True when ``tool`` carries the plugin tag written by :func:`tag_plugin_tool`.""" + metadata = getattr(tool, "metadata", None) + return isinstance(metadata, Mapping) and metadata.get(PLUGIN_TOOL_METADATA_KEY) is True + + +def get_plugin_source(tool: object) -> dict[str, Any] | None: + """Return the structurally valid plugin tag, or ``None``. + + Valid means the flag is set, every required field is a non-empty string, the + namespace matches the host charset, and ``operation`` is a non-empty string + when present. The tag's *name consistency* with the tool is checked by + :func:`resolve_tool_provenance`, which owns the ``tool.name`` comparison. + """ + metadata = getattr(tool, "metadata", None) + if not isinstance(metadata, Mapping) or metadata.get(PLUGIN_TOOL_METADATA_KEY) is not True: + return None + source = metadata.get(PLUGIN_TOOL_SOURCE_METADATA_KEY) + if not isinstance(source, Mapping): + return None + namespace = source.get("namespace") + declaration = source.get("declaration") + installation = source.get("installation") + if not all(isinstance(value, str) and value for value in (namespace, declaration, installation)): + return None + if not _NAMESPACE_RE.fullmatch(namespace): + return None + resolved: dict[str, Any] = { + "namespace": namespace, + "declaration": declaration, + "installation": installation, + } + operation = source.get("operation") + if operation is not None: + if not isinstance(operation, str) or not operation: + return None + resolved["operation"] = operation + return resolved + + +def _callable_module(tool: object) -> str: + callable_object = getattr(tool, "func", None) or getattr(tool, "coroutine", None) + return getattr(callable_object, "__module__", "") or "" + + +def resolve_tool_provenance(tool: object | None) -> ToolProvenance | None: + """Resolve *tool*'s provenance, or ``None`` when there is no tool at all. + + Every tool without a tag still resolves to a label, so a label lookup is + never ``None`` for a real tool. + """ + if tool is None: + return None + + if is_mcp_tool(tool): + source = get_mcp_source(tool) + if source is None: + return ToolProvenance(source="mcp:unknown") + return ToolProvenance( + source=f"mcp:{source['server_name']}", + mcp_server=source["server_name"], + mcp_transport=source["transport"], + ) + + plugin = get_plugin_source(tool) + if plugin is not None: + # Local import: plugin_tools tags its tools through this module, so a + # module-level import here would be a cycle. The consistency check runs + # only for tagged tools, which are host-built plugin tools. + from deerflow.extensions.plugin_tools import plugin_tool_name + + if getattr(tool, "name", None) == plugin_tool_name(plugin["namespace"], plugin["declaration"]): + return ToolProvenance( + source=f"plugin:{plugin['namespace']}", + namespace=plugin["namespace"], + declaration=plugin["declaration"], + installation=plugin["installation"], + operation=plugin.get("operation"), + ) + # A mis-attributed tag is dropped rather than mislabeling the tool. This + # is a consistency check, not an authenticity check: code able to forge + # the metadata can forge the name with it. It exists so a tag that does + # not describe *this* tool never becomes a label. + logger.warning( + "Dropping inconsistent plugin provenance tag on tool %r (declared %s/%s)", + getattr(tool, "name", tool), + plugin["namespace"], + plugin["declaration"], + ) + + metadata = getattr(tool, "metadata", None) + if isinstance(metadata, Mapping): + declared = metadata.get(DECLARED_TOOL_SOURCE_METADATA_KEY) + if isinstance(declared, str) and declared: + return ToolProvenance(source=declared) + + module = _callable_module(tool) + if module.startswith("deerflow.tools.builtins") or module.startswith("deerflow.agents.memory"): + return ToolProvenance(source="builtin") + if "skill" in module: + return ToolProvenance(source="skill") + return ToolProvenance(source="community" if module else "builtin") + + +def tool_provenance_context(tool: object | None) -> dict[str, Any]: + """Plain-mapping form of :func:`resolve_tool_provenance` for request contexts.""" + provenance = resolve_tool_provenance(tool) + if provenance is None: + return {} + context: dict[str, Any] = {"source": provenance.source} + for field in _OPTIONAL_CONTEXT_FIELDS: + value = getattr(provenance, field) + if value is not None: + context[field] = value + return context diff --git a/backend/packages/harness/pyproject.toml b/backend/packages/harness/pyproject.toml index 4d65f469d4..923fb946bf 100644 --- a/backend/packages/harness/pyproject.toml +++ b/backend/packages/harness/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ # the contract version it implements, extensions declare ranges. A range # here would let pip resolve a newer contract package than this harness # implements, making newer extensions look supported at runtime. - "deerflow-extension-api==0.2.3", + "deerflow-extension-api==0.2.4", "dotenv>=0.9.9", "exa-py>=1.0.0", "httpx>=0.28.0", diff --git a/backend/tests/blocking_io/test_plugin_authorization.py b/backend/tests/blocking_io/test_plugin_authorization.py new file mode 100644 index 0000000000..818cf76b3f --- /dev/null +++ b/backend/tests/blocking_io/test_plugin_authorization.py @@ -0,0 +1,186 @@ +"""Plugin authorization must keep its blocking work off the async event loop. + +Anchors the placement contract of ``authz/plugin_authz.py`` and +``app/gateway/authz.py``: config loading, provider *discovery* and the +synchronous ``filter_resources`` call all run in a worker, while provider +*construction* and ``aauthorize`` stay on the calling loop. Each test drives a +real production entry point with a probe that performs genuine blocking file IO, +so the gate fires if the offload is reverted. + +Each test records that its probe really ran, so an anchor cannot pass because +the production path stopped calling it. `test_gate_smoke.py` protects the gate +itself from silently losing teeth. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace +from uuid import uuid4 + +import pytest +from deerflow_extension_api.auth import ( + EXTENSION_PRINCIPAL_RESOLVER_KEY, + ExtensionPrincipal, +) +from deerflow_extension_api.plugins import BackendAction, PluginContribution +from fastapi import HTTPException +from starlette.requests import Request + +from app.gateway import authz as gateway_authz +from app.gateway.app import _resolve_extension_plugin_management_async +from app.gateway.auth_disabled import AUTH_SOURCE_SESSION +from app.gateway.routers.plugins import invoke_plugin_action +from deerflow.authz.plugin_authz import afilter_plugin_management +from deerflow.authz.provider import Principal +from deerflow.authz.rbac import RbacAuthorizationProvider +from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config +from deerflow.config.authorization_config import AuthorizationConfig, AuthorizationProviderConfig +from deerflow.config.sandbox_config import SandboxConfig +from deerflow.extensions.registry import ExtensionRegistry + +pytestmark = pytest.mark.asyncio + +NAMESPACE = "community.check" +RBAC = "deerflow.authz.rbac:RbacAuthorizationProvider" + + +def _blocking_probe(tmp_path: Path) -> Path: + path = tmp_path / "plugin-authz-blocking-probe" + path.write_text("probe", encoding="utf-8") + return path + + +def _app_config(*, roles: dict, provider_use: str = RBAC, provider_config: dict | None = None) -> AppConfig: + return AppConfig( + sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"), + authorization=AuthorizationConfig( + enabled=True, + fail_closed=True, + default_role="user", + provider=AuthorizationProviderConfig(use=provider_use, config={"roles": roles} if provider_config is None else provider_config), + ), + ) + + +def _plugin_extensions() -> object: + async def check(payload, context): # pragma: no cover - the decisions under test deny first + return {} + + plugin = PluginContribution(namespace=NAMESPACE, title="Check", enabled=True, backend=(BackendAction("check", check),)) + registry = ExtensionRegistry() + with registry.attributed_to("test:install"): + registry.plugin(plugin) + return registry.build() + + +def _unknown_request(*, extensions: object, user_id: str = "user-1") -> SimpleNamespace: + return SimpleNamespace( + app=SimpleNamespace(state=SimpleNamespace(extensions=extensions)), + state=SimpleNamespace(user=SimpleNamespace(id=user_id, system_role="user", oauth_provider=None, oauth_id=None), auth_source=AUTH_SOURCE_SESSION), + headers={}, + ) + + +def _action_request(extensions: object) -> Request: + """A real Starlette Request for the action route, without an ASGI round trip.""" + app = SimpleNamespace(state=SimpleNamespace(extensions=extensions)) + user = SimpleNamespace(id=uuid4(), system_role="user", oauth_provider=None, oauth_id=None) + request = Request({"type": "http", "app": app, "headers": [], "method": "POST"}) + request.state.user = user + request.state.auth_source = AUTH_SOURCE_SESSION + setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, lambda incoming: ExtensionPrincipal(str(incoming.state.user.id))) + return request + + +@pytest.fixture(autouse=True) +def _isolated_authorization(): + reset_app_config() + gateway_authz._plugin_provider_cache.clear() + yield + gateway_authz._plugin_provider_cache.clear() + reset_app_config() + + +async def test_action_route_offloads_config_load_and_provider_discovery(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Deny path of POST /api/plugins/{ns}/actions/{name} (the real route function).""" + probe = _blocking_probe(tmp_path) + app_config = _app_config(roles={"user": {"plugin_actions": {"allow": []}}}) + set_app_config(app_config) + + config_loads: list[str] = [] + discoveries: list[str] = [] + + def blocking_config_load() -> AppConfig: + probe.read_text(encoding="utf-8") + config_loads.append("loaded") + return app_config + + discover = gateway_authz.resolve_authorization_provider_spec + + def blocking_discovery(config: AuthorizationConfig): + probe.read_text(encoding="utf-8") + discoveries.append("discovered") + return discover(config) + + monkeypatch.setattr(gateway_authz, "_plugin_app_config", blocking_config_load) + monkeypatch.setattr(gateway_authz, "resolve_authorization_provider_spec", blocking_discovery) + + request = _action_request(_plugin_extensions()) + with pytest.raises(HTTPException) as error: + await invoke_plugin_action(request, namespace=NAMESPACE, action_name="check") + + assert error.value.status_code == 403 + # Both blocking helpers really ran — inside workers, or the gate would have raised. + assert config_loads + assert discoveries == ["discovered"] + + +async def test_async_management_resolver_offloads_the_same_resolution(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The resolver installed at EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY.""" + probe = _blocking_probe(tmp_path) + app_config = _app_config(roles={"user": {"plugin_management": {"allow": []}}}) + set_app_config(app_config) + + config_loads: list[str] = [] + + def blocking_config_load() -> AppConfig: + probe.read_text(encoding="utf-8") + config_loads.append("loaded") + return app_config + + discover = gateway_authz.resolve_authorization_provider_spec + + def blocking_discovery(config: AuthorizationConfig): + probe.read_text(encoding="utf-8") + return discover(config) + + monkeypatch.setattr(gateway_authz, "_plugin_app_config", blocking_config_load) + monkeypatch.setattr(gateway_authz, "resolve_authorization_provider_spec", blocking_discovery) + + answer = await _resolve_extension_plugin_management_async(_unknown_request(extensions=_plugin_extensions()), NAMESPACE, "read") + + assert answer is False # denied by policy, decided without blocking the loop + assert config_loads # reached inside a worker, or the gate would have raised + + +async def test_batch_decision_offloads_the_synchronous_provider_call(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """``filter_resources`` is synchronous, so the batch helpers hop to a worker.""" + probe = _blocking_probe(tmp_path) + module = ModuleType("plugin_blocking_io_test_provider") + + class BlockingFilterProvider(RbacAuthorizationProvider): + def filter_resources(self, principal, resource_type, candidates): + probe.read_text(encoding="utf-8") + return list(candidates) + + module.BlockingFilterProvider = BlockingFilterProvider + monkeypatch.setitem(sys.modules, module.__name__, module) + + app_config = _app_config(roles={"user": {}}, provider_use=f"{module.__name__}:BlockingFilterProvider", provider_config={"roles": {"user": {}}}) + candidates = [f"{NAMESPACE}/permissions.read"] + + allowed = await afilter_plugin_management(principal=Principal(user_id="user-1", role="user"), app_config=app_config, candidates=candidates) + + assert allowed == frozenset(candidates) diff --git a/backend/tests/test_app_config_reload.py b/backend/tests/test_app_config_reload.py index 847d28dd32..fe110c98c7 100644 --- a/backend/tests/test_app_config_reload.py +++ b/backend/tests/test_app_config_reload.py @@ -11,7 +11,7 @@ from pydantic import ValidationError import deerflow.config.app_config as app_config_module from deerflow.config.acp_config import load_acp_config_from_dict from deerflow.config.agents_api_config import get_agents_api_config, load_agents_api_config_from_dict -from deerflow.config.app_config import AppConfig, get_app_config, reset_app_config +from deerflow.config.app_config import AppConfig, get_app_config, peek_loaded_app_config, reset_app_config from deerflow.config.checkpointer_config import get_checkpointer_config, load_checkpointer_config_from_dict from deerflow.config.database_config import DatabaseConfig from deerflow.config.guardrails_config import get_guardrails_config, load_guardrails_config_from_dict @@ -461,6 +461,39 @@ def test_get_app_config_reloads_when_file_changes(tmp_path, monkeypatch): reset_app_config() +def test_peek_loaded_app_config_survives_a_missing_file(tmp_path, monkeypatch): + """A host running on a config keeps that value after the file disappears. + + This is the distinction ``app.gateway.authz`` needs for the plugin + authorization gate: a failed ``get_app_config()`` under a host that has a + configuration means "the policy I am running on is unreadable right now", + not "authorization was never configured". + """ + config_path = tmp_path / "config.yaml" + extensions_path = tmp_path / "extensions_config.json" + _write_extensions_config(extensions_path) + _write_config(config_path, model_name="model-a", supports_thinking=False) + + monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path)) + monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path)) + reset_app_config() + + try: + assert peek_loaded_app_config() is None + + get_app_config() + + config_path.unlink() + with pytest.raises(FileNotFoundError): + get_app_config() + + loaded = peek_loaded_app_config() + assert loaded is not None + assert loaded.models[0].name == "model-a" + finally: + reset_app_config() + + def test_get_app_config_reloads_when_content_digest_changes_without_metadata(tmp_path, monkeypatch): config_path = tmp_path / "config.yaml" extensions_path = tmp_path / "extensions_config.json" diff --git a/backend/tests/test_extension_api_contracts.py b/backend/tests/test_extension_api_contracts.py index 8885b176f0..b7db6d1558 100644 --- a/backend/tests/test_extension_api_contracts.py +++ b/backend/tests/test_extension_api_contracts.py @@ -342,7 +342,7 @@ def test_runtime_api_version_matches_the_installed_contract_package(): """Every additive contract slice bumps both gates together.""" from importlib.metadata import version - assert API_VERSION == "0.2.3" + assert API_VERSION == "0.2.4" assert API_VERSION == version("deerflow-extension-api") diff --git a/backend/tests/test_plugin_action_authorization.py b/backend/tests/test_plugin_action_authorization.py new file mode 100644 index 0000000000..1c1159d8df --- /dev/null +++ b/backend/tests/test_plugin_action_authorization.py @@ -0,0 +1,394 @@ +"""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() diff --git a/backend/tests/test_plugin_management_guard.py b/backend/tests/test_plugin_management_guard.py new file mode 100644 index 0000000000..5cd697ea71 --- /dev/null +++ b/backend/tests/test_plugin_management_guard.py @@ -0,0 +1,894 @@ +"""Plugin management authority: the public guard, the host resolvers, and the provider cache. + +Covers, in order: + +1. the fail-closed contract of ``require_plugin_management`` / ``arequire_plugin_management``; +2. the harness decision functions those helpers call; +3. the resolvers the Gateway installs on ``app.state``; +4. the loop-keyed plugin provider cache (never a cross-loop or cross-sync reuse). +""" + +from __future__ import annotations + +import asyncio +import sys +from types import ModuleType, SimpleNamespace + +import pytest +from deerflow_extension_api import ( + EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY, + EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY, + EXTENSION_PRINCIPAL_RESOLVER_KEY, + ExtensionPrincipal, + arequire_plugin_management, + require_plugin_management, +) +from deerflow_extension_api.plugins import BackendAction, PluginContribution + +from app.gateway import authz as gateway_authz +from app.gateway.auth_disabled import AUTH_SOURCE_SESSION +from deerflow.authz.plugin_authz import ( + PluginAuthorizationError, + aenforce_plugin_action, + aenforce_plugin_management, + afilter_plugin_management, + afilter_plugin_pages, + enforce_plugin_action, + enforce_plugin_management, +) +from deerflow.authz.plugin_targets import ( + MANAGEMENT_READ_PART, + MANAGEMENT_WRITE_PART, + plugin_management_target, +) +from deerflow.authz.provider import AuthzDecision, AuthzReason, AuthzRequest, Principal +from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config +from deerflow.config.authorization_config import AuthorizationConfig, AuthorizationProviderConfig +from deerflow.config.sandbox_config import SandboxConfig +from deerflow.extensions.registry import ExtensionRegistry + +RBAC = "deerflow.authz.rbac:RbacAuthorizationProvider" +NAMESPACE = "community.check" +READ_TARGET = plugin_management_target(NAMESPACE, MANAGEMENT_READ_PART) +WRITE_TARGET = plugin_management_target(NAMESPACE, MANAGEMENT_WRITE_PART) + + +# --- Shared helpers ------------------------------------------------------------ + + +class _RecordingProvider: + """Records which entry point a caller used, so placement is observable.""" + + name = "recording" + + def __init__(self, *, allow: bool = True, fail: str | None = None, decision: object = None) -> None: + self.sync_requests: list[AuthzRequest] = [] + self.async_requests: list[AuthzRequest] = [] + self.filters: list[tuple[str, list[str]]] = [] + self._allow = allow + self._fail = fail + self._decision = decision + + def _decide(self) -> object: + if self._fail == "authorize": + raise RuntimeError("provider failed") + if self._decision is not None: + return self._decision + return AuthzDecision(allow=self._allow, reasons=[] if self._allow else [AuthzReason(code="authz.denied")]) + + def authorize(self, request: AuthzRequest): + self.sync_requests.append(request) + return self._decide() + + async def aauthorize(self, request: AuthzRequest): + self.async_requests.append(request) + return self._decide() + + def filter_resources(self, principal: Principal, resource_type: str, candidates: list[str]): + if self._fail == "filter": + raise RuntimeError("provider failed") + self.filters.append((resource_type, list(candidates))) + return [candidate for candidate in candidates if self._allow] + + +class _ExplodingReasons: + """An iterable that satisfies ``isinstance(reasons, Iterable)`` and raises while consumed. + + Two ``__iter__`` shapes: raising outright, or yielding a non-``AuthzReason`` + and then raising from ``__next__``. Both are reusable — every ``__iter__`` + call builds a fresh generator. + """ + + def __init__(self, *, raises_while_iterating: bool = False) -> None: + self._raises_while_iterating = raises_while_iterating + + def __iter__(self): + if not self._raises_while_iterating: + raise RuntimeError("reasons.__iter__ failed") + return self._explode() + + def _explode(self): + yield "not-a-reason" + raise RuntimeError("reasons.__next__ failed") + + +def _authorization_config(*, enabled: bool = True, fail_closed: bool = True, roles: dict | None = None, provider_use: str = RBAC, provider_config: dict | None = None, default_role: str = "user") -> AuthorizationConfig: + if provider_config is None: + provider_config = {"roles": roles or {}} if provider_use == RBAC else {} + return AuthorizationConfig( + enabled=enabled, + fail_closed=fail_closed, + default_role=default_role, + provider=AuthorizationProviderConfig(use=provider_use, config=provider_config), + ) + + +def _app_config(**kwargs) -> AppConfig: + return AppConfig(sandbox=SandboxConfig(use="test"), authorization=_authorization_config(**kwargs)) + + +def _principal(role: str = "user") -> Principal: + return Principal(user_id="user-1", role=role) + + +def _use_provider(monkeypatch: pytest.MonkeyPatch, provider: object) -> None: + """Serve *provider* from every resolution path the decision layer can take.""" + import deerflow.authz.plugin_authz as plugin_authz + + monkeypatch.setattr(plugin_authz, "resolve_authorization_provider", lambda config: provider) + monkeypatch.setattr(plugin_authz, "resolve_authorization_provider_spec", lambda config: object()) + monkeypatch.setattr(plugin_authz, "construct_authorization_provider", lambda spec, config: provider) + + +_DEFAULT_USER = object() + + +def _plain_request(*, user: object = _DEFAULT_USER, auth_source: str = AUTH_SOURCE_SESSION, app: object | None = None) -> SimpleNamespace: + """A request-like object; pass ``user=None`` for an anonymous caller.""" + if user is _DEFAULT_USER: + user = SimpleNamespace(id="user-1", system_role="user", oauth_provider=None, oauth_id=None) + return SimpleNamespace(app=app, state=SimpleNamespace(user=user, auth_source=auth_source), headers={}) + + +# --- 1. The public guard ------------------------------------------------------- + + +_GUARD_PRINCIPAL = ExtensionPrincipal("u1") + + +def _guard_request(*, principal: ExtensionPrincipal | None = _GUARD_PRINCIPAL, **state: object) -> SimpleNamespace: + attributes: dict[str, object] = {EXTENSION_PRINCIPAL_RESOLVER_KEY: (lambda request: principal)} + attributes.update(state) + return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(**attributes))) + + +def test_guard_returns_the_principal_when_the_host_allows(): + request = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY: lambda request, namespace, scope: True}) + + assert require_plugin_management(request, NAMESPACE, scope="write") == _GUARD_PRINCIPAL + + +@pytest.mark.parametrize("answer", [False, None, "yes", 1, 0]) +def test_guard_rejects_every_non_true_answer(answer): + request = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY: lambda request, namespace, scope: answer}) + + with pytest.raises(PermissionError): + require_plugin_management(request, NAMESPACE) + + +def test_guard_fails_closed_without_a_resolver(): + with pytest.raises(PermissionError): + require_plugin_management(_guard_request(), NAMESPACE) + + +def test_guard_fails_closed_when_the_resolver_fails(): + def boom(request, namespace, scope): + raise RuntimeError("nope") + + request = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY: boom}) + + with pytest.raises(PermissionError): + require_plugin_management(request, NAMESPACE) + + +def test_guard_fails_closed_without_an_authenticated_principal(): + """Identity is asked before authority: no identity is not an answer.""" + request = _guard_request(principal=None, **{EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY: lambda request, namespace, scope: True}) + + with pytest.raises(PermissionError): + require_plugin_management(request, NAMESPACE) + + +@pytest.mark.parametrize("scope", ["admin", "", "READ", None]) +def test_guard_rejects_an_unknown_scope(scope): + request = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY: lambda request, namespace, scope: True}) + + with pytest.raises(ValueError): + require_plugin_management(request, NAMESPACE, scope=scope) + + +@pytest.mark.parametrize("namespace", ["", "Bad", "a/b", 1, None]) +def test_guard_rejects_a_malformed_namespace(namespace): + request = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY: lambda request, namespace, scope: True}) + + with pytest.raises(ValueError): + require_plugin_management(request, namespace) + + +def test_guard_asks_the_resolver_with_the_caller_namespace_and_scope(): + seen = [] + + def resolver(request, namespace, scope): + seen.append((namespace, scope)) + return True + + require_plugin_management(_guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY: resolver}), NAMESPACE, scope="write") + + assert seen == [(NAMESPACE, "write")] + + +@pytest.mark.asyncio +async def test_async_guard_allows_and_denies(): + async def allow(request, namespace, scope): + return True + + async def deny(request, namespace, scope): + return False + + allowed = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY: allow}) + assert await arequire_plugin_management(allowed, NAMESPACE) == _GUARD_PRINCIPAL + + denied = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY: deny}) + with pytest.raises(PermissionError): + await arequire_plugin_management(denied, NAMESPACE) + + +@pytest.mark.asyncio +async def test_async_guard_never_falls_back_to_the_sync_resolver(): + """Running the sync answer here would put the host's config read on the loop.""" + sync_only = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY: lambda request, namespace, scope: True}) + with pytest.raises(PermissionError): + await arequire_plugin_management(sync_only, NAMESPACE) + + async_only = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY: lambda request, namespace, scope: True}) + with pytest.raises(PermissionError): + require_plugin_management(async_only, NAMESPACE) + + +@pytest.mark.asyncio +async def test_async_guard_fails_closed_on_a_sync_resolver_and_on_errors(): + def wrong_shape(request, namespace, scope): + return True + + async def boom(request, namespace, scope): + raise RuntimeError("nope") + + for resolver in (wrong_shape, boom): + request = _guard_request(**{EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY: resolver}) + with pytest.raises(PermissionError): + await arequire_plugin_management(request, NAMESPACE) + + +# --- 2. The decision layer ----------------------------------------------------- + + +def test_action_decision_uses_the_composed_target(monkeypatch): + provider = _RecordingProvider() + _use_provider(monkeypatch, provider) + + enforce_plugin_action(principal=_principal(), app_config=_app_config(), namespace=NAMESPACE, action_name="check") + + (request,) = provider.sync_requests + assert (request.resource, request.action, request.target) == ("plugin_action", "invoke", f"{NAMESPACE}/check") + + +def test_management_read_and_write_are_distinct_targets(monkeypatch): + provider = _RecordingProvider() + _use_provider(monkeypatch, provider) + + enforce_plugin_management(principal=_principal(), app_config=_app_config(), namespace=NAMESPACE, write=False) + enforce_plugin_management(principal=_principal(), app_config=_app_config(), namespace=NAMESPACE, write=True) + + assert [(request.resource, request.action, request.target) for request in provider.sync_requests] == [ + ("plugin_management", "read", READ_TARGET), + ("plugin_management", "write", WRITE_TARGET), + ] + + +def test_built_in_provider_separates_read_from_write_authority(): + """One resource, two targets, two verdicts — the built-in provider ignores ``action``.""" + config = _app_config(roles={"user": {"plugin_management": {"allow": [READ_TARGET]}}}) + + enforce_plugin_management(principal=_principal(), app_config=config, namespace=NAMESPACE, write=False) + + with pytest.raises(PluginAuthorizationError) as error: + enforce_plugin_management(principal=_principal(), app_config=config, namespace=NAMESPACE, write=True) + assert (error.value.resource, error.value.target) == ("plugin_management", WRITE_TARGET) + assert error.value.fail_closed is True + + +def test_deny_reports_the_provider_reason(monkeypatch): + provider = _RecordingProvider(allow=False) + _use_provider(monkeypatch, provider) + + with pytest.raises(PluginAuthorizationError) as error: + enforce_plugin_action(principal=_principal(), app_config=_app_config(), namespace=NAMESPACE, action_name="check") + + assert error.value.reason_code == "authz.denied" + assert error.value.resource == "plugin_action" + assert error.value.target == f"{NAMESPACE}/check" + + +def test_disabled_authorization_never_asks_the_provider(monkeypatch): + provider = _RecordingProvider() + _use_provider(monkeypatch, provider) + config = _app_config(enabled=False) + + enforce_plugin_action(principal=_principal(), app_config=config, namespace=NAMESPACE, action_name="check") + enforce_plugin_management(principal=_principal(), app_config=config, namespace=NAMESPACE, write=True) + + assert provider.sync_requests == [] + + +@pytest.mark.parametrize("fail_closed", [True, False]) +@pytest.mark.parametrize("provider_kwargs", [{"fail": "authorize"}, {"decision": "not-a-decision"}]) +def test_unanswerable_decisions_follow_fail_closed(monkeypatch, fail_closed: bool, provider_kwargs: dict): + provider = _RecordingProvider(**provider_kwargs) + _use_provider(monkeypatch, provider) + config = _app_config(fail_closed=fail_closed) + + if fail_closed: + with pytest.raises(PluginAuthorizationError) as error: + enforce_plugin_action(principal=_principal(), app_config=config, namespace=NAMESPACE, action_name="check") + assert error.value.reason_code == "authz.provider_error" + else: + enforce_plugin_action(principal=_principal(), app_config=config, namespace=NAMESPACE, action_name="check") + + +@pytest.mark.parametrize("fail_closed", [True, False]) +def test_a_missing_principal_follows_fail_closed(monkeypatch, fail_closed: bool): + provider = _RecordingProvider() + _use_provider(monkeypatch, provider) + + if fail_closed: + with pytest.raises(PluginAuthorizationError) as error: + enforce_plugin_management(principal=None, app_config=_app_config(), namespace=NAMESPACE, write=False) + assert error.value.reason_code == "authz.no_principal" + else: + enforce_plugin_management(principal=None, app_config=_app_config(fail_closed=False), namespace=NAMESPACE, write=False) + assert provider.sync_requests == [] + + +@pytest.mark.asyncio +async def test_async_decisions_await_the_async_provider(monkeypatch): + provider = _RecordingProvider() + _use_provider(monkeypatch, provider) + config = _app_config() + + await aenforce_plugin_action(principal=_principal(), app_config=config, namespace=NAMESPACE, action_name="check") + await aenforce_plugin_management(principal=_principal(), app_config=config, namespace=NAMESPACE, write=True) + + assert provider.sync_requests == [] + assert [(request.resource, request.target) for request in provider.async_requests] == [ + ("plugin_action", f"{NAMESPACE}/check"), + ("plugin_management", WRITE_TARGET), + ] + + +@pytest.mark.asyncio +async def test_async_decision_honours_fail_closed(monkeypatch): + _use_provider(monkeypatch, _RecordingProvider(fail="authorize")) + + with pytest.raises(PluginAuthorizationError): + await aenforce_plugin_action(principal=_principal(), app_config=_app_config(), namespace=NAMESPACE, action_name="check") + + await aenforce_plugin_action(principal=_principal(), app_config=_app_config(fail_closed=False), namespace=NAMESPACE, action_name="check") + + +def test_an_unreadable_config_denies_instead_of_allowing(monkeypatch): + """Review P1: config *unavailable* is not the same as a disabled policy. + + The flag that would permit an allow cannot be read, so the check fails closed + rather than silently passing the request. + """ + _use_provider(monkeypatch, _RecordingProvider()) + + with pytest.raises(PluginAuthorizationError) as error: + enforce_plugin_action(principal=_principal(), app_config=None, namespace=NAMESPACE, action_name="check") + + assert error.value.reason_code == "authz.config_unavailable" + assert error.value.fail_closed is True + + +@pytest.mark.asyncio +async def test_an_unreadable_config_denies_the_async_paths(monkeypatch): + _use_provider(monkeypatch, _RecordingProvider()) + + with pytest.raises(PluginAuthorizationError) as error: + await aenforce_plugin_management(principal=_principal(), app_config=None, namespace=NAMESPACE, write=True) + assert error.value.reason_code == "authz.config_unavailable" + + with pytest.raises(PluginAuthorizationError): + await afilter_plugin_management(principal=_principal(), app_config=None, candidates=[READ_TARGET]) + + +@pytest.mark.parametrize("allow", ["false", "true", 1, 0, [], None]) +@pytest.mark.parametrize("fail_closed", [True, False]) +def test_a_non_bool_allow_is_a_malformed_decision(monkeypatch, allow, fail_closed): + """Review P2: ``AuthzDecision(allow="false")`` is truthy and must not be an allow.""" + _use_provider(monkeypatch, _RecordingProvider(decision=AuthzDecision(allow=allow))) + config = _app_config(fail_closed=fail_closed) + + if fail_closed: + with pytest.raises(PluginAuthorizationError) as error: + enforce_plugin_management(principal=_principal(), app_config=config, namespace=NAMESPACE, write=True) + assert error.value.reason_code == "authz.provider_error" + else: + enforce_plugin_management(principal=_principal(), app_config=config, namespace=NAMESPACE, write=True) + + +@pytest.mark.parametrize("reasons", [None, 5, _ExplodingReasons(), _ExplodingReasons(raises_while_iterating=True)]) +@pytest.mark.parametrize("fail_closed", [True, False]) +def test_malformed_reasons_keep_the_denial(monkeypatch, reasons, fail_closed): + """Review P2/P3: a denied verdict with unusable ``reasons`` still denies. + + ``AuthzDecision`` is a plain dataclass, so ``reasons`` can be any object — + including one that passes the ``Iterable`` check and then raises from + ``__iter__``/``__next__``. The verdict is a valid denial (``allow`` is a + real ``bool``), so the denial stands and only the informative code degrades: + extracting it must not raise, or a denial would escape as an unhandled error + instead of ``403``. + """ + _use_provider(monkeypatch, _RecordingProvider(decision=AuthzDecision(allow=False, reasons=reasons))) + + with pytest.raises(PluginAuthorizationError) as error: + enforce_plugin_management(principal=_principal(), app_config=_app_config(fail_closed=fail_closed), namespace=NAMESPACE, write=True) + + assert error.value.reason_code == "authz.denied" + assert error.value.fail_closed is fail_closed + + +@pytest.mark.asyncio +@pytest.mark.parametrize("reasons", [None, 5, _ExplodingReasons(), _ExplodingReasons(raises_while_iterating=True)]) +async def test_async_malformed_reasons_keep_the_denial(monkeypatch, reasons): + _use_provider(monkeypatch, _RecordingProvider(decision=AuthzDecision(allow=False, reasons=reasons))) + + with pytest.raises(PluginAuthorizationError) as error: + await aenforce_plugin_action(principal=_principal(), app_config=_app_config(), namespace=NAMESPACE, action_name="check") + + assert error.value.reason_code == "authz.denied" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("allow", ["false", 1]) +@pytest.mark.parametrize("fail_closed", [True, False]) +async def test_async_non_bool_allow_is_a_malformed_decision(monkeypatch, allow, fail_closed): + _use_provider(monkeypatch, _RecordingProvider(decision=AuthzDecision(allow=allow))) + config = _app_config(fail_closed=fail_closed) + + if fail_closed: + with pytest.raises(PluginAuthorizationError) as error: + await aenforce_plugin_action(principal=_principal(), app_config=config, namespace=NAMESPACE, action_name="check") + assert error.value.reason_code == "authz.provider_error" + else: + await aenforce_plugin_action(principal=_principal(), app_config=config, namespace=NAMESPACE, action_name="check") + + +@pytest.mark.asyncio +async def test_batch_filters_use_the_batch_entry_point(monkeypatch): + provider = _RecordingProvider() + _use_provider(monkeypatch, provider) + config = _app_config() + + allowed = await afilter_plugin_management(principal=_principal(), app_config=config, candidates=[READ_TARGET, WRITE_TARGET]) + pages = await afilter_plugin_pages(principal=_principal(), app_config=config, candidates=[f"{NAMESPACE}/reports"]) + + assert allowed == frozenset({READ_TARGET, WRITE_TARGET}) + assert pages == frozenset({f"{NAMESPACE}/reports"}) + assert provider.filters == [ + ("plugin_management", [READ_TARGET, WRITE_TARGET]), + ("plugin_page", [f"{NAMESPACE}/reports"]), + ] + + +@pytest.mark.asyncio +async def test_batch_filter_never_returns_a_target_it_was_not_asked_about(monkeypatch): + """A provider answer can only narrow the candidates, never widen them. + + ``filter_resources`` is a plain method on a custom provider, so an answer + naming an unrelated target (``"*"``, another namespace) must not reach the + projection as visible authority. + """ + + class _OversharingProvider(_RecordingProvider): + def filter_resources(self, principal, resource_type, candidates): + return [*candidates, "*", f"{NAMESPACE}/injected"] + + _use_provider(monkeypatch, _OversharingProvider()) + + pages = await afilter_plugin_pages(principal=_principal(), app_config=_app_config(), candidates=[f"{NAMESPACE}/reports"]) + management = await afilter_plugin_management(principal=_principal(), app_config=_app_config(), candidates=[READ_TARGET]) + + assert pages == frozenset({f"{NAMESPACE}/reports"}) + assert management == frozenset({READ_TARGET}) + + +@pytest.mark.asyncio +async def test_batch_filter_denies_every_candidate(monkeypatch): + _use_provider(monkeypatch, _RecordingProvider(allow=False)) + + allowed = await afilter_plugin_management(principal=_principal(), app_config=_app_config(), candidates=[READ_TARGET, WRITE_TARGET]) + + assert allowed == frozenset() + + +@pytest.mark.asyncio +async def test_batch_filters_skip_the_provider_without_candidates(monkeypatch): + """No candidate means no provider round trip.""" + provider = _RecordingProvider() + _use_provider(monkeypatch, provider) + + assert await afilter_plugin_pages(principal=_principal(), app_config=_app_config(), candidates=[]) == frozenset() + assert provider.filters == [] + + +@pytest.mark.asyncio +async def test_batch_filters_are_unrestricted_while_authorization_is_disabled(monkeypatch): + provider = _RecordingProvider(allow=False) + _use_provider(monkeypatch, provider) + config = _app_config(enabled=False) + + allowed = await afilter_plugin_management(principal=_principal(), app_config=config, candidates=[READ_TARGET]) + + assert allowed == frozenset({READ_TARGET}) + assert provider.filters == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("fail_closed", [True, False]) +async def test_batch_filter_failure_follows_fail_closed(monkeypatch, fail_closed: bool): + _use_provider(monkeypatch, _RecordingProvider(fail="filter")) + config = _app_config(fail_closed=fail_closed) + + if fail_closed: + with pytest.raises(PluginAuthorizationError) as error: + await afilter_plugin_management(principal=_principal(), app_config=config, candidates=[READ_TARGET]) + assert error.value.reason_code == "authz.provider_error" + else: + assert await afilter_plugin_management(principal=_principal(), app_config=config, candidates=[READ_TARGET]) == frozenset({READ_TARGET}) + + +# --- 3. The host-installed resolvers ------------------------------------------- + + +def _plugin_extensions(*, namespace: str = NAMESPACE) -> object: + async def check(payload, context): # pragma: no cover - never invoked + return {} + + plugin = PluginContribution(namespace=namespace, title="Check", enabled=True, backend=(BackendAction("check", check),)) + registry = ExtensionRegistry() + with registry.attributed_to("test:install"): + registry.plugin(plugin) + return registry.build() + + +@pytest.fixture +def host_app(monkeypatch: pytest.MonkeyPatch): + """A real ``create_app()`` with a stub config and our own plugin snapshot.""" + import app.gateway.app as app_module + from deerflow.extensions import reset_loaded_extensions, reset_runtime_diagnostics + + reset_app_config() + gateway_authz._plugin_provider_cache.clear() + monkeypatch.setattr(app_module, "get_app_config", lambda: AppConfig(sandbox=SandboxConfig(use="test"))) + reset_loaded_extensions() + reset_runtime_diagnostics() + from app.gateway.app import create_app + + app = create_app() + app.state.extensions = _plugin_extensions() + try: + yield app + finally: + gateway_authz._plugin_provider_cache.clear() + reset_app_config() + reset_loaded_extensions() + reset_runtime_diagnostics() + + +def test_gateway_installs_both_plugin_management_resolvers(host_app): + assert callable(getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY, None)) + assert callable(getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY, None)) + + +def test_installed_resolver_is_a_noop_while_authorization_is_disabled(host_app): + set_app_config(_app_config(enabled=False)) + resolver = getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY) + request = _plain_request(app=host_app) + + assert resolver(request, NAMESPACE, "read") is True + assert resolver(request, NAMESPACE, "write") is True + # And the public helper therefore allows an enterprise route. + assert require_plugin_management(request, NAMESPACE, scope="write").user_id == "user-1" + + +def test_installed_resolvers_decide_read_and_write_independently(host_app): + set_app_config(_app_config(roles={"user": {"plugin_management": {"allow": [READ_TARGET]}}})) + request = _plain_request(app=host_app) + + assert require_plugin_management(request, NAMESPACE, scope="read").user_id == "user-1" + with pytest.raises(PermissionError): + require_plugin_management(request, NAMESPACE, scope="write") + + +def test_installed_resolvers_cannot_answer_an_unknown_plugin(host_app): + set_app_config(_app_config(roles={"user": {"plugin_management": {"allow": "*"}}})) + request = _plain_request(app=host_app) + resolver = getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY) + + assert resolver(request, "community.missing", "read") is None + with pytest.raises(PermissionError): + require_plugin_management(request, "community.missing") + + +def test_installed_resolvers_cannot_answer_an_anonymous_caller(host_app): + set_app_config(_app_config(roles={"user": {"plugin_management": {"allow": "*"}}})) + resolver = getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY) + + assert resolver(_plain_request(user=None, app=host_app), NAMESPACE, "read") is None + + +@pytest.mark.asyncio +async def test_installed_async_resolver_matches_the_sync_answer(host_app): + set_app_config(_app_config(roles={"user": {"plugin_management": {"allow": [READ_TARGET]}}})) + request = _plain_request(app=host_app) + + assert (await arequire_plugin_management(request, NAMESPACE, scope="read")).user_id == "user-1" + with pytest.raises(PermissionError): + await arequire_plugin_management(request, NAMESPACE, scope="write") + + +def test_installed_resolver_denies_when_the_config_cannot_be_read(host_app, monkeypatch): + """Review P1: a config read failure must not resolve to 'allowed'.""" + set_app_config(_app_config(roles={"user": {"plugin_management": {"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) + + resolver = getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY) + + assert resolver(_plain_request(app=host_app), NAMESPACE, "read") is False + + +def test_installed_resolver_allows_when_no_config_exists(host_app, monkeypatch): + """A host that never had a config has no policy to apply: no gate. + + Same rule the route-scoped gates use (``_get_route_authorization_config``, + ``deerflow.authz.sandbox_authz.safe_app_config``): authorization can only be + enabled through config, so a ``config.yaml``-less host (CI runner, direct + call, a host that mounts only the plugins router) keeps today's behavior. + """ + + def absent_config(): + raise FileNotFoundError("`config.yaml` file not found") + + monkeypatch.setattr("deerflow.config.app_config.get_app_config", absent_config) + monkeypatch.setattr("deerflow.config.get_app_config", absent_config) + + resolver = getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY) + + assert resolver(_plain_request(app=host_app), NAMESPACE, "read") is True + + +@pytest.mark.parametrize("fail_closed,expected", [(True, False), (False, True)]) +def test_installed_resolver_applies_the_running_policy_when_the_config_disappears(host_app, monkeypatch, fail_closed: bool, expected: bool): + """Review P1: a host that lost the config it is running on must not answer 'allowed'. + + The configuration this process loaded decides the failure: an enabled policy + follows its own ``fail_closed`` (``True`` by default) instead of the read + failure being read as "authorization was never configured". + """ + set_app_config(_app_config(fail_closed=fail_closed, roles={"user": {"plugin_management": {"allow": "*"}}})) + + def absent_config(): + raise FileNotFoundError("`config.yaml` file not found") + + monkeypatch.setattr("deerflow.config.app_config.get_app_config", absent_config) + monkeypatch.setattr("deerflow.config.get_app_config", absent_config) + + resolver = getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY) + + assert resolver(_plain_request(app=host_app), NAMESPACE, "read") is expected + + +@pytest.mark.asyncio +async def test_installed_async_resolver_denies_when_the_config_disappears(host_app, monkeypatch): + """The async resolver reaches the same answer through ``aresolve_plugin_authorization``.""" + set_app_config(_app_config(roles={"user": {"plugin_management": {"allow": "*"}}})) + + def absent_config(): + raise FileNotFoundError("`config.yaml` file not found") + + monkeypatch.setattr("deerflow.config.app_config.get_app_config", absent_config) + monkeypatch.setattr("deerflow.config.get_app_config", absent_config) + + with pytest.raises(PermissionError): + await arequire_plugin_management(_plain_request(app=host_app), NAMESPACE, scope="read") + + +def test_installed_resolver_stays_noop_when_the_lost_config_had_authorization_off(host_app, monkeypatch): + """The running policy decides: a loaded config with authorization off has no gate to apply.""" + set_app_config(_app_config(enabled=False)) + + def absent_config(): + raise FileNotFoundError("`config.yaml` file not found") + + monkeypatch.setattr("deerflow.config.app_config.get_app_config", absent_config) + monkeypatch.setattr("deerflow.config.get_app_config", absent_config) + + resolver = getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY) + + assert resolver(_plain_request(app=host_app), NAMESPACE, "read") is True + + +@pytest.mark.parametrize("fail_closed,expected", [(True, False), (False, True)]) +def test_installed_resolver_applies_the_failure_policy(host_app, fail_closed: bool, expected: bool): + set_app_config(_app_config(fail_closed=fail_closed, provider_use="nonexistent.module:FakeProvider")) + resolver = getattr(host_app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY) + + assert resolver(_plain_request(app=host_app), NAMESPACE, "read") is expected + + +# --- 4. Provider lifecycle ----------------------------------------------------- + + +def _loop_affine_module(monkeypatch: pytest.MonkeyPatch) -> tuple[str, list]: + """A provider whose construction is only valid on a running event loop.""" + module = ModuleType("plugin_provider_lifecycle_test_module") + constructed: list = [] + + class LoopAffineProvider: + name = "loop-affine" + + def __init__(self, *, tolerate_no_loop: bool = False) -> None: + try: + self.loop = asyncio.get_running_loop() + except RuntimeError: + if not tolerate_no_loop: + raise + self.loop = None + constructed.append(self.loop) + + def authorize(self, request): + return AuthzDecision(allow=True) + + async def aauthorize(self, request): + return AuthzDecision(allow=True) + + def filter_resources(self, principal, resource_type, candidates): + return list(candidates) + + module.LoopAffineProvider = LoopAffineProvider + monkeypatch.setitem(sys.modules, module.__name__, module) + return f"{module.__name__}:LoopAffineProvider", constructed + + +@pytest.fixture(autouse=True) +def _isolated_provider_cache(): + gateway_authz._plugin_provider_cache.clear() + reset_app_config() + yield + gateway_authz._plugin_provider_cache.clear() + reset_app_config() + + +@pytest.mark.asyncio +async def test_async_resolution_constructs_on_the_calling_loop(monkeypatch): + class_path, constructed = _loop_affine_module(monkeypatch) + set_app_config(_app_config(provider_use=class_path)) + + provider, principal, app_config = await gateway_authz.aresolve_plugin_authorization(_plain_request()) + + running_loop = asyncio.get_running_loop() + assert provider is not None and provider.loop is running_loop + assert constructed == [running_loop] + assert principal is not None and principal.user_id == "user-1" + assert app_config is not None and app_config.authorization.enabled is True + + +@pytest.mark.asyncio +async def test_a_hot_path_neither_rediscovers_nor_reconstructs(monkeypatch): + class_path, constructed = _loop_affine_module(monkeypatch) + set_app_config(_app_config(provider_use=class_path)) + request = _plain_request() + first, _, _ = await gateway_authz.aresolve_plugin_authorization(request) + + discoveries: list = [] + monkeypatch.setattr(gateway_authz, "resolve_authorization_provider_spec", lambda config: discoveries.append(1)) + + second, _, _ = await gateway_authz.aresolve_plugin_authorization(request) + + assert second is first + assert constructed == [asyncio.get_running_loop()] + assert discoveries == [] + + +@pytest.mark.asyncio +async def test_a_configuration_change_rebuilds_the_provider(monkeypatch): + class_path, constructed = _loop_affine_module(monkeypatch) + set_app_config(_app_config(provider_use=class_path)) + first, _, _ = await gateway_authz.aresolve_plugin_authorization(_plain_request()) + + set_app_config(_app_config(provider_use=class_path, default_role="guest")) + + second, _, _ = await gateway_authz.aresolve_plugin_authorization(_plain_request()) + assert second is not first + assert len(constructed) == 2 + + +def test_the_sync_slot_never_reuses_a_loop_provider(monkeypatch): + class_path, constructed = _loop_affine_module(monkeypatch) + config = _app_config(provider_use=class_path, provider_config={"tolerate_no_loop": True}) + set_app_config(config) + + async def _async_resolve(): + return (await gateway_authz.aresolve_plugin_authorization(_plain_request()))[0] + + loop_provider = asyncio.run(_async_resolve()) + sync_provider, principal, sync_config = gateway_authz.resolve_plugin_authorization(_plain_request()) + + assert loop_provider is not None and loop_provider.loop is not None + assert sync_provider is not None + assert sync_provider is not loop_provider + assert sync_provider.loop is None + assert principal is not None + + # The sync caller keeps its own slot on the next call. + assert gateway_authz.resolve_plugin_authorization(_plain_request())[0] is sync_provider + assert len(constructed) == 2 + + +def test_each_event_loop_gets_its_own_provider(monkeypatch): + class_path, constructed = _loop_affine_module(monkeypatch) + set_app_config(_app_config(provider_use=class_path, provider_config={"tolerate_no_loop": True})) + + async def _async_resolve(): + return (await gateway_authz.aresolve_plugin_authorization(_plain_request()))[0] + + first = asyncio.run(_async_resolve()) + second = asyncio.run(_async_resolve()) + + assert first is not second + assert first.loop is not second.loop + assert len(constructed) == 2 + + +@pytest.mark.asyncio +async def test_concurrent_cold_requests_still_decide_correctly(monkeypatch): + """No single-flight lock: a duplicate construction is accepted, not a failure.""" + class_path, constructed = _loop_affine_module(monkeypatch) + set_app_config(_app_config(provider_use=class_path)) + + first, second = await asyncio.gather( + gateway_authz.aresolve_plugin_authorization(_plain_request()), + gateway_authz.aresolve_plugin_authorization(_plain_request()), + ) + + assert len(constructed) == 2 + for provider, principal, _snapshot in (first, second): + assert provider is not None and principal is not None + decision = await provider.aauthorize(AuthzRequest(principal=principal, resource="plugin_action", action="invoke", target=f"{NAMESPACE}/check")) + assert decision.allow is True + + +def test_anonymous_callers_get_a_provider_without_a_principal(monkeypatch): + class_path, _ = _loop_affine_module(monkeypatch) + set_app_config(_app_config(provider_use=class_path, provider_config={"tolerate_no_loop": True})) + + provider, principal, app_config = gateway_authz.resolve_plugin_authorization(_plain_request(user=None)) + + assert provider is not None + assert principal is None diff --git a/backend/tests/test_plugin_targets.py b/backend/tests/test_plugin_targets.py new file mode 100644 index 0000000000..5d3af319d5 --- /dev/null +++ b/backend/tests/test_plugin_targets.py @@ -0,0 +1,185 @@ +"""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 diff --git a/backend/tests/test_plugin_tools.py b/backend/tests/test_plugin_tools.py index 0007bc8f83..e44b584067 100644 --- a/backend/tests/test_plugin_tools.py +++ b/backend/tests/test_plugin_tools.py @@ -84,6 +84,25 @@ def test_group_filter_and_name_collision_fail_closed(installed): build_plugin_tools(loaded, reserved_names={tool.name}) +def test_built_tools_carry_host_recorded_plugin_provenance(installed): + """The tag is written at construction, from registry-validated values.""" + from deerflow.tools.tool_provenance import get_plugin_source, is_plugin_tool, resolve_tool_provenance + + loaded, plugin, _ = installed + (tool,) = build_plugin_tools(loaded) + + assert is_plugin_tool(tool) is True + assert get_plugin_source(tool) == { + "namespace": plugin.namespace, + "declaration": "search", + "installation": "test", + } + provenance = resolve_tool_provenance(tool) + assert provenance is not None + assert provenance.source == f"plugin:{plugin.namespace}" + assert provenance.declaration == "search" + + @pytest.mark.parametrize("source", ["config", "builtin", "mcp", "acp"]) def test_assembly_keeps_ordinary_and_unaffected_plugin_tools_on_collision(installed, monkeypatch, caplog, source): from langchain_core.tools import Tool diff --git a/backend/tests/test_rbac_authorization_provider.py b/backend/tests/test_rbac_authorization_provider.py index e793d45d2d..64ca0ecac7 100644 --- a/backend/tests/test_rbac_authorization_provider.py +++ b/backend/tests/test_rbac_authorization_provider.py @@ -103,6 +103,7 @@ class TestResourceMapping: ("skill", "skills"), ("mcp_server", "mcp_servers"), ("route", "routes"), + ("plugin_action", "plugin_actions"), ], ) def test_reserved_request_alias_is_rejected(self, request_alias, config_key): @@ -143,6 +144,40 @@ class TestResourceMapping: # No model policy configured → unrestricted assert p.authorize(_make_request(resource="model", target="any")).allow is True + def test_plugin_action_maps_to_plugin_actions(self): + """The request resource is singular; the config key is plural.""" + p = _provider({"user": {"plugin_actions": {"allow": ["community.check/check"]}}}) + request = _make_request(resource="plugin_action", action="invoke", target="community.check/check") + assert p.authorize(request).allow is True + assert p.authorize(_make_request(resource="plugin_action", action="invoke", target="community.check/other")).allow is False + assert p.filter_resources(Principal(role="user"), "plugin_action", ["community.check/check", "community.check/other"]) == ["community.check/check"] + + def test_plugin_actions_alias_direction_denies_when_empty(self): + """A reversed alias would silently allow; the mapping is request → config key.""" + p = _provider({"user": {"plugin_actions": {"allow": []}}}) + assert p.authorize(_make_request(resource="plugin_action", action="invoke", target="community.check/check")).allow is False + + def test_plugin_management_is_self_mapped(self): + """Key == resource is legal (same shape as ``sandbox``).""" + p = _provider({"user": {"plugin_management": {"allow": ["community.check/permissions.read"]}}}) + allowed = p.authorize(_make_request(resource="plugin_management", action="read", target="community.check/permissions.read")) + denied = p.authorize(_make_request(resource="plugin_management", action="write", target="community.check/permissions.write")) + assert allowed.allow is True + assert denied.allow is False + + def test_plugin_management_separates_read_from_write_by_target(self): + """The built-in provider ignores ``action``, so authority must differ by target.""" + from deerflow.authz.plugin_targets import MANAGEMENT_READ_PART, MANAGEMENT_WRITE_PART, plugin_management_target + + read_target = plugin_management_target("community.check", MANAGEMENT_READ_PART) + write_target = plugin_management_target("community.check", MANAGEMENT_WRITE_PART) + p = _provider({"user": {"plugin_management": {"allow": [read_target]}}}) + + assert p.authorize(_make_request(resource="plugin_management", action="read", target=read_target)).allow is True + # The same call under the write action is still denied: the target carries the authority. + assert p.authorize(_make_request(resource="plugin_management", action="write", target=write_target)).allow is False + assert p.authorize(_make_request(resource="plugin_management", action="write", target=read_target)).allow is True + # --- Role resolution --- diff --git a/backend/tests/test_tool_provenance.py b/backend/tests/test_tool_provenance.py new file mode 100644 index 0000000000..71f9522499 --- /dev/null +++ b/backend/tests/test_tool_provenance.py @@ -0,0 +1,204 @@ +"""Tool provenance: display attribution only, with a fixed precedence. + +The plugin tag is written by the host at construction; every other source keeps +the heuristic label it had. Nothing here is a trust input — the one decision +that needs certainty (the Layer-2 infrastructure exemption) compares object +identity, not a label. +""" + +from __future__ import annotations + +import logging + +import pytest +from langchain_core.tools import Tool + +from deerflow.agents.assembly_descriptor import describe_tool +from deerflow.extensions.plugin_tools import plugin_tool_name +from deerflow.tools.mcp_metadata import MCP_TOOL_METADATA_KEY, MCP_TOOL_SOURCE_METADATA_KEY +from deerflow.tools.tool_provenance import ( + PLUGIN_TOOL_METADATA_KEY, + PLUGIN_TOOL_SOURCE_METADATA_KEY, + get_plugin_source, + is_plugin_tool, + resolve_tool_provenance, + tag_plugin_tool, + tool_provenance_context, +) + +NAMESPACE = "acme.reports" +INSTALLATION = "acme.plugin:install" + + +def _tool(module: str = "some.vendor.tools", name: str = "gadget") -> Tool: + def func(**_kwargs): + return "ok" + + func.__module__ = module + return Tool(name=name, description="A tool", func=func) + + +def _plugin_tool(namespace: str = NAMESPACE, declaration: str = "export", *, installation: str = INSTALLATION, operation: str | None = None) -> Tool: + tool = _tool(name=plugin_tool_name(namespace, declaration)) + tag_plugin_tool(tool, namespace=namespace, declaration=declaration, installation=installation, operation=operation) + return tool + + +# --- Tag round trip ------------------------------------------------------------ + + +def test_plugin_tag_round_trips_into_provenance_and_context(): + tool = _plugin_tool() + + assert is_plugin_tool(tool) is True + provenance = resolve_tool_provenance(tool) + assert provenance is not None + assert provenance.source == f"plugin:{NAMESPACE}" + assert provenance.namespace == NAMESPACE + assert provenance.declaration == "export" + assert provenance.installation == INSTALLATION + assert provenance.operation is None + assert tool_provenance_context(tool) == { + "source": f"plugin:{NAMESPACE}", + "namespace": NAMESPACE, + "declaration": "export", + "installation": INSTALLATION, + } + + +def test_declared_shared_operation_is_carried_through(): + tool = _plugin_tool(operation="acme.reports.export") + + provenance = resolve_tool_provenance(tool) + assert provenance is not None and provenance.operation == "acme.reports.export" + assert tool_provenance_context(tool)["operation"] == "acme.reports.export" + + +def test_plugin_tag_is_read_by_the_assembly_descriptor(): + """The descriptor identity and the execution-time label cannot disagree.""" + assert describe_tool(_plugin_tool()).source == f"plugin:{NAMESPACE}" + + +def test_tagging_preserves_unrelated_metadata(): + tool = _tool() + tool.metadata = {"kept": "value"} + + tag_plugin_tool(tool, namespace=NAMESPACE, declaration="export", installation=INSTALLATION) + + assert tool.metadata["kept"] == "value" + assert tool.metadata[PLUGIN_TOOL_METADATA_KEY] is True + assert tool.metadata[PLUGIN_TOOL_SOURCE_METADATA_KEY]["declaration"] == "export" + + +# --- Consistency and precedence ------------------------------------------------ + + +def test_mis_attributed_tag_is_dropped_with_a_warning(caplog: pytest.LogCaptureFixture): + """A tag whose name is not this plugin's generated name never becomes a label.""" + tool = _tool(name="gadget") + tag_plugin_tool(tool, namespace=NAMESPACE, declaration="export", installation=INSTALLATION) + + with caplog.at_level(logging.WARNING): + provenance = resolve_tool_provenance(tool) + + assert provenance is not None + assert provenance.source == "community" # the heuristic the tool would have had + assert provenance.namespace is None + assert "inconsistent plugin provenance tag" in caplog.text + + +@pytest.mark.parametrize( + "source", + [ + {"namespace": "BAD NAMESPACE", "declaration": "export", "installation": INSTALLATION}, + {"namespace": NAMESPACE, "declaration": "", "installation": INSTALLATION}, + {"namespace": NAMESPACE, "declaration": "export", "installation": None}, + {"namespace": NAMESPACE, "declaration": "export", "installation": INSTALLATION, "operation": ""}, + "not-a-mapping", + ], +) +def test_structurally_invalid_tags_are_not_plugin_provenance(source): + tool = _plugin_tool() + tool.metadata = {**(tool.metadata or {}), PLUGIN_TOOL_SOURCE_METADATA_KEY: source} + + assert is_plugin_tool(tool) is True + assert get_plugin_source(tool) is None + provenance = resolve_tool_provenance(tool) + assert provenance is not None and provenance.source == "community" + + +def test_mcp_flag_wins_over_a_plugin_tag(): + """MCP tools are tagged first in the documented precedence.""" + tool = _plugin_tool() + tool.metadata = { + **(tool.metadata or {}), + MCP_TOOL_METADATA_KEY: True, + MCP_TOOL_SOURCE_METADATA_KEY: {"server_name": "filesystem", "transport": "stdio"}, + } + + provenance = resolve_tool_provenance(tool) + assert provenance is not None + assert provenance.source == "mcp:filesystem" + assert provenance.mcp_server == "filesystem" + assert provenance.mcp_transport == "stdio" + assert provenance.namespace is None + + +def test_an_mcp_flag_without_source_details_stays_labelled(): + tool = _tool() + tool.metadata = {MCP_TOOL_METADATA_KEY: True} + + provenance = resolve_tool_provenance(tool) + assert provenance is not None and provenance.source == "mcp:unknown" + + +def test_declared_tool_source_is_honoured_for_an_untagged_tool(): + """Out-of-repo tool builders keep their declaration-controlled display label.""" + tool = _tool() + tool.metadata = {"deerflow_tool_source": "custom-vendor"} + + provenance = resolve_tool_provenance(tool) + assert provenance is not None and provenance.source == "custom-vendor" + + +def test_plugin_tag_is_checked_before_a_declared_source(): + tool = _plugin_tool() + tool.metadata = {**(tool.metadata or {}), "deerflow_tool_source": "custom-vendor"} + + provenance = resolve_tool_provenance(tool) + assert provenance is not None and provenance.source == f"plugin:{NAMESPACE}" + + +# --- Heuristic fallbacks (unchanged display labels) ----------------------------- + + +@pytest.mark.parametrize( + "module,expected", + [ + ("deerflow.tools.builtins.web_search", "builtin"), + ("deerflow.agents.memory.manager", "builtin"), + ("deerflow.skills.loader", "skill"), + ("some.vendor.tools", "community"), + ("", "builtin"), # no usable __module__ on the callable + ], +) +def test_module_heuristic_labels_are_unchanged(module: str, expected: str): + provenance = resolve_tool_provenance(_tool(module=module)) + assert provenance is not None and provenance.source == expected + + +def test_every_real_tool_resolves_to_a_label(): + """An untagged source can never turn a label lookup into ``None``.""" + for tool in (_tool(), _tool(module=""), _plugin_tool()): + provenance = resolve_tool_provenance(tool) + assert provenance is not None and provenance.source + + +def test_missing_tool_resolves_to_nothing(): + assert resolve_tool_provenance(None) is None + assert tool_provenance_context(None) == {} + + +def test_context_form_omits_absent_optional_fields(): + context = tool_provenance_context(_tool(module="deerflow.tools.builtins.web_search")) + assert context == {"source": "builtin"} diff --git a/backend/uv.lock b/backend/uv.lock index bc2d609ef5..0befc65557 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -918,7 +918,7 @@ extensions = [] [[package]] name = "deerflow-extension-api" -version = "0.2.3" +version = "0.2.4" source = { editable = "packages/extension-api" } [[package]] diff --git a/config.example.yaml b/config.example.yaml index 2f3cdb918d..3bc5888a3f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -20,7 +20,7 @@ # ============================================================================ # Bump this number when the config schema changes. # Run `make config-upgrade` to merge new fields into your local config.yaml. -config_version: 48 +config_version: 49 # Optional operator-owned literal prompt extensions (see CONFIGURATION.md): # lead_prompt_overlay: @@ -2991,6 +2991,16 @@ run_ownership: # # (which models/skills the caller sees on GET /api/models and the # # GET /api/skills[.../custom, /{name}] skill routes); skill # # management endpoints (install/edit/export) stay admin-gated. +# # +# # Plugin resources: `plugin_actions` gates invoking a registered +# # backend action (POST /api/plugins/{ns}/actions/{name}), and +# # `plugin_management` gates an enterprise-contributed management route +# # that calls require_plugin_management. Targets are +# # "/" and "/permissions.read" +# # / "/permissions.write". **Omitting a key leaves that +# # resource unrestricted for the role**, so list it explicitly to +# # constrain it. Declared plugin pages are not gated yet: their +# # `plugin_pages` row ships with the page-authorization slice. # roles: # admin: # tools: {allow: "*"} @@ -2998,12 +3008,16 @@ run_ownership: # models: {allow: "*"} # sandbox: {allow: "*"} # skills: {allow: "*"} +# plugin_actions: {allow: "*"} +# plugin_management: {allow: "*"} # user: # tools: {allow: "*", deny: ["update_agent"]} # routes: {allow: "*"} # models: {allow: "*"} # sandbox: {allow: "*"} # skills: {allow: "*"} +# plugin_actions: {allow: "*"} # invoke any installed action +# plugin_management: {allow: []} # no management route access # guest: # # web-only role: sandbox-dependent tools (read_file, bash, glob, # # grep, write_file, ...) are omitted — with sandbox:execute denied @@ -3014,6 +3028,8 @@ run_ownership: # models: {allow: ["gpt-4o-mini"]} # sandbox: {allow: false} # deny sandbox execution # skills: {allow: []} # no skills visible in the listing +# plugin_actions: {allow: ["acme.reports/export"]} +# plugin_management: {allow: ["acme.reports/permissions.read"]} authorization: enabled: false diff --git a/deploy/helm/deer-flow/README.md b/deploy/helm/deer-flow/README.md index ad98073b5b..79db3d2fe5 100644 --- a/deploy/helm/deer-flow/README.md +++ b/deploy/helm/deer-flow/README.md @@ -135,7 +135,7 @@ they resolve from the `secrets` map): ```yaml config: | - config_version: 48 + config_version: 49 models: - name: gpt-4 use: langchain_openai:ChatOpenAI diff --git a/deploy/helm/deer-flow/values.yaml b/deploy/helm/deer-flow/values.yaml index 1115f4ac6d..d790fd2c75 100644 --- a/deploy/helm/deer-flow/values.yaml +++ b/deploy/helm/deer-flow/values.yaml @@ -249,7 +249,7 @@ ingress: # -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never # inline literal secret values here. The default enables provisioner sandbox. config: | - config_version: 48 + config_version: 49 log_level: info recursion_limit: 100 max_recursion_limit: 1000 diff --git a/docs/full-stack-plugins.md b/docs/full-stack-plugins.md index 0abd0ebc1e..2a10a2091e 100644 --- a/docs/full-stack-plugins.md +++ b/docs/full-stack-plugins.md @@ -3,7 +3,7 @@ A deployment-installed Python extension can register a `PluginContribution` with optional browser code, authenticated backend actions and model tools. This extends the existing `install(registry, config)` workflow. MCP and Skills keep their existing -APIs and lifecycles. Public contracts live in `deerflow_extension_api` (0.2.3). +APIs and lifecycles. Public contracts live in `deerflow_extension_api` (0.2.4). The browser contribution API is experimental. `BrowserModule(code=...)` remains the self-contained transport; `BrowserAssets(root=...)` adds manifest-listed resources @@ -212,6 +212,117 @@ The host does not rewrite CSS URLs or add authorization tokens to URLs. CSP must the backend origin in the applicable `script-src`, `style-src`, `img-src`, `font-src` and `connect-src` directives. Inline-v1 still needs `blob:` in `script-src`. +## Authorization of plugin surfaces + +Fine-grained authorization (`authorization:` in `config.yaml`; the native RFC is +[`docs/plans/2026-07-10-pluggable-authorization-rfc.md`](plans/2026-07-10-pluggable-authorization-rfc.md)) +is enforced on two plugin entry points when it is enabled. With +`authorization.enabled: false` both are no-ops and today's behavior is +unchanged, on every entry point. + +| Entry point | `resource` | `action` | `target` | +| --- | --- | --- | --- | +| Registered backend action | `plugin_action` | `invoke` | `/` | +| Enterprise management route | `plugin_management` | `read` / `write` | `/permissions.read` / `/permissions.write` | + +Targets are composed and validated by `deerflow.authz.plugin_targets`; the left +side is always a host-validated plugin namespace. Read and write management +authority are separate **targets** (the built-in provider ignores `action`), and +page access never implies write authority. + +The registered-action check runs in `POST /api/plugins/{namespace}/actions/{name}` +after the action has been resolved — so an unknown action stays a 404 — and +**before** the request body is read, so a denied caller cannot consume the +256 KiB input budget or reach the handler. A denial is +`403 {"detail": "Plugin action not permitted for your role."}`; `fail_closed` +(see below) decides whether a provider failure denies or proceeds. + +The built-in RBAC provider reads these from `authorization.provider.config.roles` +as `plugin_actions` and `plugin_management`. A role with no policy for a +resource is **unrestricted** for it, so list these keys explicitly wherever you +want to constrain them; `config.example.yaml` shows the shape. + +### Guarding a contributed management route + +A contributed router is not covered by the registered-action dispatcher, so it +asks for plugin-scoped authority itself: + +```python +from deerflow_extension_api import arequire_plugin_management, require_admin +from fastapi import HTTPException, Request + +@router.get("/{namespace}/permissions") +async def read_permissions(namespace: str, request: Request): + try: + principal = await arequire_plugin_management(request, namespace, scope="read") + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + ... # read the enterprise's own policy store + +@router.put("/{namespace}/permissions") +async def write_permissions(namespace: str, request: Request): + try: + principal = await arequire_plugin_management(request, namespace, scope="write") + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + ... # validate delegation/scope, then commit +``` + +- The helper asks the host's configured provider, with the host's trusted + request identity; it never reads or writes enterprise policy itself. +- It **fails closed**: an unknown namespace, an anonymous caller, a missing or + failing host resolver, or a non-allowed decision raises `PermissionError`. +- That `PermissionError` is not an HTTP response. The Gateway installs no + `PermissionError` handler, so a route that lets it escape answers `500`, not + `403`; translate it at your own boundary as the example does. The host's other + contributed-route safeguards — authentication, CSRF, PAT rejection — run + before your handler and none of them covers a policy denial. +- When authorization is disabled the host answers "allow", so the helper is a + no-op and a deployment that turns authorization off does not start rejecting + enterprise routes. An enterprise that needs an unconditional floor keeps + calling `require_admin` as well. +- "Cannot answer" includes a configuration the host cannot read right now: a + config file mid-write or briefly absent while an editor, a deploy or a + ConfigMap mount replaces it, or a document that does not validate. The + decision uses the config snapshot captured when the request's provider was + resolved, and a host that is running on a configuration never reads such a + failure as "authorization is disabled": it follows the configured failure + policy, which denies under the default `fail_closed: true`. A host with no + configuration at all has no policy to apply, so the helper stays a no-op. +- Use `arequire_plugin_management` from an async endpoint. `require_plugin_management` + is the synchronous form for a FastAPI `def` endpoint, which FastAPI runs in + its thread pool; calling it from an async endpoint would do the host's + configuration read on the event loop. +- The plugin namespace must be an installed plugin's namespace, and the + decision is re-evaluated per request: an execution-time policy change is + observed by the next call. + +### Documented boundaries + +- **Contributed routers are not covered by the registered-action dispatcher.** + Only registered actions go through the automatic check; a contributed router + uses `require_plugin_management` for plugin-scoped checks and keeps its own + administrator floor where one is needed. Arbitrary in-process plugin code is + trusted operator-installed code and RBAC does not sandbox it. +- **Declared plugin pages are not gated yet.** A page's navigation entry, direct + route and mount are still decided by the plugin's `enabled` setting alone; + server-evaluated page authorization arrives with the page-declaration + contract. Until then a browser surface must keep checking its own backend + operations, which *are* covered above. +- **Runtime-injected tools stay Layer-2 decisions.** Tool declarations added + through `request.tools` inside `wrap_model_call`, and model calls naming a tool + absent from the bound tool set, are not part of assembly-time narrowing; the + execution-time provider decides each call by name, like every other tool call. +- **`create_deerflow_agent` (`deerflow.agents.factory`) still performs no + authorization.** Pre-existing and unchanged by this work. +- **Durable tasks keep today's contract.** Only the exposed submission wrapper is + checked; raw status/cancel service calls and their ownership checks are + unchanged. +- **The pre-existing route/model/skill authorization cache is untouched.** It + keeps its synchronous lifecycle and call sites; the plugin paths use a + separate provider cache keyed by configuration signature and event loop, so a + loop-affine provider is never handed to another loop. + ## Trust and lifecycle **Browser and Python plugins are trusted operator-installed code.** Browser modules diff --git a/docs/plans/2026-07-10-pluggable-authorization-implementation-notes.md b/docs/plans/2026-07-10-pluggable-authorization-implementation-notes.md index 05cc0f2955..93179d4e7c 100644 --- a/docs/plans/2026-07-10-pluggable-authorization-implementation-notes.md +++ b/docs/plans/2026-07-10-pluggable-authorization-implementation-notes.md @@ -483,6 +483,221 @@ Phase 1 最低验证要求: 各追加条目。前端 effective-permissions 展示剩余项;management route 的 provider 迁移(沿袭前阶段延期项)。 +### 2026-09-24 — Phase 5 / PR1 — 插件资源授权管道(targets、决策层、action 检查、management 守卫) + +- **背景:** 设计文档 `2026-09-23-extension-tool-authorization-coverage-gaps.md` 与实施规格 + `2026-09-24-extension-tool-authorization-spec.md`(rev 5)把「插件工具/页面授权覆盖缺口」 + 切成三个 PR:PR1 = 插件管道(target 编码、provenance 展示、`plugin_authz`、action 检查、 + management 守卫、provider 生命周期、公开 helper + 一次契约版本提升),PR2 = 工具链路 + (middleware 声明工具、identity-bound exemption),PR3 = 页面切片。本条记录 PR1。 +- **决策(target 编码):** 新增 `deerflow/authz/plugin_targets.py`,复合 target 一律 + `"{namespace}/{part}"`,由唯一 `_join` 校验器编码:namespace 字符集与 + `config/plugin_settings.py` 一致,action / surface id / management part 分别镜像 registry、 + 浏览器 surface 规则和两个模块常量。三个构造器各带 kind 检查,调用点禁止字符串拼接; + 非法输入抛 `PluginTargetError`(`ValueError` 子类),绝不返回 best-effort 字符串。 +- **决策(RBAC 别名):** `_RESOURCE_POLICY_KEYS` 新增 `"plugin_action" → "plugin_actions"` + 与 `"plugin_management" → "plugin_management"`(自映射,合法,同 `sandbox`)。左值仍是请求 + `resource`,右值是 `config.yaml` 键;把 `plugin_action` 当作 config 键会在构造期被拒 + (reserved request alias)。`plugin_page → plugin_pages` 随 PR3 落地——该 PR 才产生 + `plugin_page` 资源,缺 key = 不受限仍是不变的语义。 +- **决策(决策层):** 新增 `deerflow/authz/plugin_authz.py`:六个决策函数 + + `PluginAuthorizationError`(携带 resource / target / reason_code / fail_closed)。disabled → + no-op;显式 deny 抛错;provider 异常、解析失败、malformed decision、无 principal 一律按 + `fail_closed` 抛错或 warning 放行(镜像 sandbox gate 的语义,而不是工具过滤器的静默集合 + 语义)。异步批量决策用 `asyncio.to_thread(filter_resources)`,因此 + `AuthorizationProvider.filter_resources` 的 docstring 增加一句线程安全要求(仅文档, + 三个方法和签名不变)。 +- **决策(provider 生命周期):** 插件请求路径**不**复用既有 route provider cache(同步、 + 在事件循环上构造、仅按 config identity 命中,且无 blocking-IO anchor)。新增按 + `(config signature, loop_key)` 键的缓存:同步调用者用私有 `_SYNC_SLOT`,异步按 + `id(asyncio.get_running_loop())`;两者互不串用,也不跨 loop 复用;config 读取与 + `resolve_authorization_provider_spec` 在线程中执行,`construct_authorization_provider` + 保持在调用方 loop 上;无 single-flight 锁(允许并发冷启动重复构造,已文档化); + 写入时清理已关闭 loop 的条目。 +- **决策(公开 helper + 契约版本):** `deerflow_extension_api.auth` 新增 + `EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY`、`EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY` 与 + `require_plugin_management` / `arequire_plugin_management`(只读 `request.app.state`,保持 + 契约包不依赖 `deerflow`)。两者 fail closed:解析器缺失/失败/返回 `None`、未知 namespace、 + 非 `True` 答案都抛 `PermissionError`;`authorization.enabled: false` 时宿主回答 `True` + (no-op),因此不会开始拒绝企业路由,需要无条件底线的企业仍叠加 `require_admin`。同步形式 + 供 FastAPI `def` 端点(线程池)使用,异步端点使用 `a` 版本。契约版本 `0.2.3 → 0.2.4` + (`API_VERSION`、包 version、harness 精确 pin、lockfile、契约测试四处同步)。 +- **决策(action 检查):** `POST /api/plugins/{ns}/actions/{name}` 在 action 解析之后 + (未知 action 仍是 404,不改变存在性预言)、读取请求体之前插入一次 `plugin_action` 决策; + deny → `403 "Plugin action not permitted for your role."`,handler 不被调用,被拒调用者 + 无法占用 256 KiB 输入预算。 +- **证据:** 新增 `tests/test_plugin_targets.py`(字符集、跨 kind 同形 target、 + management part、非法输入)、`tests/test_tool_provenance.py`(plugin tag 往返、错配 tag 被丢弃、 + MCP 优先级、`deerflow_tool_source` 与模块回退标签保持)、 + `tests/test_plugin_action_authorization.py`(allow/deny/未授权先于 body、 + fail_closed/fail_open、disabled no-op、未知 action 404、internal 身份)、 + `tests/test_plugin_management_guard.py`(helper 的 fail-closed 矩阵、宿主解析器安装与 + 未知 namespace、disabled 放行、loop 生命周期与只读投影前置)、 + `tests/blocking_io/test_plugin_authorization.py`(config/discovery 不在事件循环上, + 含 red→green teeth);扩展 `test_rbac_authorization_provider.py`、`test_plugin_tools.py`、 + `test_extension_api_contracts.py`。 +- **兼容性:** `authorization.enabled: false` 时 action 路由与 management helper 均为 no-op; + 既有工具 resource 名与 `AuthorizationProvider` Protocol 未变;`config_version` 47 → 49 + (与主线的并发 bump 竞争后按「每次更新 PR 前的固定清单」重取下一个可用号,见下方合并记录; + `config.example.yaml` + Helm values + Helm README 三处同步);`config.example.yaml` + roles 新增 `plugin_actions` / `plugin_management` 两行并注明「省略 key = 该资源不受限」。 +- **否决方案:** 不为节省一次构造而复用 route cache(会把一个 loop 上的 loop-affine provider + 交给另一个 loop);不在请求边界拼接 target(必须走 `plugin_targets`);不在 PR1 引入 + `plugin_page` 别名(没有生产者,属 PR3);不把插件检查做成第二个常开 gate。 +- **延期:** PR2(工具链路:middleware 声明工具、`LayerOneOutcome` 种子、build-local view、 + identity-bound infrastructure exemption、`GuardrailRequest.tool_provenance`/`tool_identity`)、 + PR3(`pages`/`shared_operation` 声明门、`extensions/catalog.py`、`/api/plugins` 只读投影、 + 前端页面 gate、企业示例与剩余文档)。 + +### 2026-09-24 — Phase 5 / PR1 review round 1(PR #5842,willem-bd) + +- **背景:** 静态审查对 PR1 提出两条授权缺陷:P1 —— 决策层为了判断 `enabled`/`fail_closed` + 会**第二次**读取配置,而 Gateway 已经用可读配置解析出 provider/principal;这次重读若在 + 热重载窗口内失败(文件缺失/暂时非法),`_enabled_config(None)` 会把「配置不可用」当作 + 「授权已关闭」,于是受保护的插件 action/management 请求在未询问 `fail_closed` 的情况下被放行。 + P2 —— `AuthzDecision` 是普通 dataclass,自定义 provider 可以返回 + `AuthzDecision(allow="false")`;`isinstance` 校验通过后真值字符串被当作 allow。 +- **决策(P1,采纳审查建议的第一种修法):** 决策层不再自行读取配置。`plugin_authz` 的六个函数 + 把 `app_config` 改为**必填**,语义是「调用方用于解析 provider 的那个请求级快照」; + §5.3-C 的两个 Gateway 解析辅助函数改为返回 `(provider, principal, app_config)` 三元组, + 并把同一快照交给执行层,因此一次请求只有一次配置读取,也不存在两次读取之间的状态漂移。 + 快照为 `None`(调用方完全读不到配置)时,视为**不可用**而非「已关闭」: + 以 `authz.config_unavailable` + `fail_closed=True` 失败关闭(能放行的那个开关本身读不到)。 + 同时 Gateway 侧新增 `_plugin_app_config[_async]`,把「不存在 config.yaml」(`FileNotFoundError` + → 今日行为,无门禁) 与「配置存在但当前读不了/解析不了」(异常上抛 → 拒绝)区分开; + 后者不再被 `safe_app_config` 的宽泛 `except Exception` 吞掉。 +- **决策(P2):** 新增 `_validated_decision`,要求返回值是 `AuthzDecision` 且 + `type(decision.allow) is bool`(同步 `authorize` 与异步 `aauthorize` 两条路径都走它); + 非 bool 的 `allow` 与 `AuthzDecision` 类型错误一样按 provider 失败处理,遵循 `fail_closed`。 +- **证据:** 新增回归测试并做了反证(revert 修复后必须变红)—— + `test_unreadable_config_denies_the_action`(200 vs 403)、 + `test_installed_resolver_denies_when_the_config_cannot_be_read`(True vs False)、 + `test_a_non_bool_allow_is_a_malformed_decision[...]`(12 项红)与对应异步/路由用例、 + `test_installed_resolver_allows_when_no_config_exists`(不存在 config.yaml 仍为放行)、 + `test_no_config_at_all_keeps_todays_behavior`。全部 95 项插件套件在修复后通过。 +- **兼容性:** `authorization.enabled: false` 与「没有 config.yaml」两种情形行为不变; + harness 内部函数签名由「可选 app_config」变为「必填快照」,属 PR1 内的内部契约调整, + 不涉及 `deerflow_extension_api` 公开面(公开 helper 的 fail-closed 语义只在“宿主读不到配置” + 时更严格)。 +- **延期:** 不变(工具链路 PR2、页面切片 PR3)。 + +### 2026-09-25 — Phase 5 / PR1 review round 2(PR #5842,willem-bd) + +- **背景:** 静态审查对当前 head(`62197d96`)再提两条:P1 —— `_plugin_app_config` 仍把 + `FileNotFoundError`(请求时刻文件不在)映射成「授权从未启用」并返回 `None`,调用方据此放行 + 受保护的插件 action 与 management 路由;但真实 Gateway 不可能在没有可读 `config.yaml` 的情况下 + 启动(`app/gateway/app.py` 的 `lifespan` 用同一个访问器读配置,失败即 `RuntimeError`), + 因此请求期的「缺失」只意味着这份配置在运行中变得不可用(热重载 / 原子替换窗口)。 + P2 —— `_deny_reason_code` 在 provider 校验的 `try` 之外遍历 `decision.reasons`, + 自定义 provider 返回 `AuthzDecision(allow=False, reasons=None)`(或任何不可迭代对象)时抛 + `TypeError`,把一次拒绝变成未捕获错误,而不是配置好的授权失败(403)。 +- **决策(P1,采纳审查建议的第一种修法):** 用「这个进程是否在跑一份配置」区分「不可用」与「未配置」, + 而不是用异常类型或无条件失败关闭: + - 读取失败且进程**从未加载过配置**(`peek_loaded_app_config() is None`;`deerflow.config.app_config` + 新增只读访问器,覆盖 `get_app_config()` 成功缓存与 `set_app_config()` 注入两种来源):授权只能由 + 配置开启,故**无门禁**放行——与同一文件里的 `_get_route_authorization_config()` + (`(FileNotFoundError, RuntimeError)` → disabled)和 `sandbox_authz.safe_app_config()` + (“no readable config ⇒ the sandbox gate is a no-op … CI runners and direct-call tests”)同一条规则。 + 挂载 plugins router 的 e2e 宿主(`backend/extension_test_fixtures/bookmark_plugin_gateway.py`) + 正属此类。 + - 读取失败且进程**正在跑一份配置**:这是「可用但当前读不到」,异常上抛, + `resolve_/aresolve_plugin_authorization` 以 `_PluginAuthorizationUnavailable(fail_closed=…)` + 失败处理,标志由**那份配置自己**给出(`enabled` 非 `True` → 无门禁;enabled → 取该策略的 + `fail_closed`,默认 `true`)。于是启用授权且 fail-closed 的宿主在窗口内返回 403 / `False`, + 不再像以前那样被当成「授权从未开启」放行。 + - 文件存在但读不了/解析不了(非 `FileNotFoundError`):无已加载配置时沿用 round 1 的 fail-closed + (能放行的那个开关读不到);有已加载配置时同样取该策略的 `fail_closed`。 + *被取代的规则:* round 1 记录的「不存在 `config.yaml` → 今日行为,无门禁」,以及本轮中间版本的 + 「任何读取失败都无条件失败关闭(`fail_closed=True`)」。 +- **修正记录:** 本轮先按审查字面实现「缺失即无条件失败关闭」,本地与 CI e2e 立刻变红 + (`frontend/tests/e2e/bookmark-plugin.spec.ts` 的 save action 由 200 变 403——该宿主本来就 + 没有 `config.yaml`),且与 `_get_route_authorization_config()`、`safe_app_config()` 的既有规则 + 冲突。随后改为上面的「按进程已加载的配置」判定:没有配置的宿主行为不变,丢失配置的宿主不再被 + 当成未配置。 +- **决策(P2):** 拒绝码提取改为全函数:`reasons` 不可迭代时直接退化为 `authz.denied`, + 元素校验额外要求 `reason.code` 是非空 `str`。刻意**不**并入 `_validated_decision` 的「畸形判决」 + 判定:`allow` 已是真正的 `bool`,这条判决本身是合法拒绝;若按畸形判决走 `fail_closed`, + 在 `fail_closed: false` 下会把明确拒绝翻成放行。 +- **证据:** 新增/改写回归测试并做了反证(修复前必须变红)—— + `test_installed_resolver_allows_when_no_config_exists`(无配置宿主:True)、 + `test_installed_resolver_applies_the_running_policy_when_the_config_disappears[True/False]`、 + `test_installed_async_resolver_denies_when_the_config_disappears`、 + `test_installed_resolver_stays_noop_when_the_lost_config_had_authorization_off`、 + `test_a_lost_config_follows_the_running_policy[403/200]`、`test_no_config_at_all_keeps_todays_behavior`、 + `test_peek_loaded_app_config_survives_a_missing_file`(文件删除后 `peek_loaded_app_config()` + 仍返回已加载配置),以及 round 1 的 `test_unreadable_config_denies_the_action` / + `test_installed_resolver_denies_when_the_config_cannot_be_read`(非 `FileNotFoundError` 仍然 fail-closed); + P2 侧 `test_malformed_reasons_keep_the_denial[...]` / `test_async_malformed_reasons_keep_the_denial[...]` + (`TypeError: 'NoneType' object is not iterable`,`plugin_authz.py:113`)。 + e2e 层面复现了 spec 的后端一半:拉起 `extension_test_fixtures.bookmark_plugin_gateway` 后 + POST `/api/plugins/community.bookmarks/actions/save`,中间版本 `403`(与 CI 的 3 个 bookmark 用例 + 失败一致),最终版本 `200`。验证:配置模块测试 41 项、插件/extension + blocking-IO 429 项、 + `ruff check` 与 `format --check` 全绿。 +- **否决方案:** ①「缺失即无条件失败关闭」:与既有 `safe_app_config` / `_get_route_authorization_config` + 规则冲突,并打断 e2e 宿主的插件 action(见上)。②「Gateway 侧发布 startup 快照」:状态要手动接线、 + 且只反映启动那一刻的策略(热重载后的新策略不会生效),改用配置模块自己缓存的「最后一次成功加载」 + 更准确且无需额外生命周期钩子。 +- **兼容性:** 没有配置的宿主(CI runner、直接调用、只挂 plugins router 的宿主)行为完全不变; + 配置可读且 `authorization.enabled: false` 时不变(no-op);只有「运行中配置消失/读不了」由 + 静默放行改为按该策略的 `fail_closed` 处理(默认拒绝)。 +- **延期:** 不变(工具链路 PR2、页面切片 PR3)。相邻风险已记录但未改:同一文件里的 + `_get_route_authorization_config()`(model / skill / sandbox 路由门)对读取失败仍一律回退到 + disabled,存在同类窗口;如需同样收紧应另开一条(影响面覆盖全部路由门,超出本轮范围)。 + +### 2026-09-25 — Phase 5 / PR1 review round 3(PR #5842,willem-bd) + +- **背景:** 静态审查对当前 head(`a891e704`)追加一条 P3 —— round 2 的 P2 修复只挡住了 + 「`reasons` 不是 `Iterable`」,遍历本身仍在 provider 校验的 `try` 之外:生成器或自定义可迭代 + 对象可以满足 `isinstance(reasons, Iterable)`,却在 `__iter__`/`__next__` 中抛错,于是一次明确 + 拒绝仍会以未捕获错误(500)逃逸,而不是按配置的授权失败退化为 `authz.denied`。 +- **决策(采纳审查建议的第一种修法):** `_deny_reason_code` 的**整段提取**(取属性、`Iterable` + 判定、遍历)包进一个窄 `try/except Exception`,任何异常都降级为 `authz.denied`,并记一条 + `logger.debug(..., exc_info=True)`;函数签名、返回值域与「拒绝仍是拒绝」的语义不变。 + 不采纳第二种修法(把 `reasons` 校验成声明的具体 list 形状):合法 provider 返回 tuple 或生成器 + 形态的 `AuthzReason` 时,那会把**本来可读**的拒绝码一并降级为 `authz.denied`,属无谓的行为收窄; + 「`Iterable` 判定 + 全函数提取」既保留合法可迭代对象,又对抛错对象收敛。 +- **证据:** 红先验证(修复前必须变红)——管理器层 + `test_malformed_reasons_keep_the_denial[True|False-reasons2|reasons3]`(4 项)与异步 + `test_async_malformed_reasons_keep_the_denial[reasons2|reasons3]`(2 项)修复前抛 + `RuntimeError: reasons.__iter__ failed` / `RuntimeError: reasons.__next__ failed` + (`plugin_authz.py:127`),修复后降级为 + `PluginAuthorizationError(reason_code="authz.denied")`;新增的 `_ExplodingReasons` 覆盖两种形态: + `__iter__` 直接抛,以及先产出一个非 `AuthzReason` 再从 `__next__` 抛。路由层 + `test_unreadable_reasons_deny_instead_of_erroring[True|False]` 修复前让异常穿出路由(未捕获), + 修复后 `403` 且 handler 未被调用——两种 `fail_closed` 取值下都保持拒绝 + (`fail_closed: false` 不得把明确拒绝翻成放行)。验证:两个插件测试文件 115 项、 + `tests/blocking_io` 163 项、`ruff check` 与 `ruff format --check` 全绿。 +- **兼容性:** 拒绝码提取的返回值域不变(`str`,无法读取时为 `authz.denied`);只有「可迭代对象在 + 遍历时抛错」这一种输入从 500 变为降级后的 403。 +- **延期:** 不变(工具链路 PR2、页面切片 PR3)。相邻同类风险已记录但未改: + `deerflow/authz/adapter.py` 的 `_to_guardrail` 仍直接遍历 `d.reasons`,而消费它的 + `GuardrailMiddleware` 把 provider 异常按 `fail_closed` 处理,因此 `fail_closed: false` 时 + 「显式拒绝 + reasons 遍历抛错」可能被翻成放行;该文件不在本 PR 面内(工具链路), + 如需同样收敛应随 PR2 一并处理。 + +### 2026-09-25 — Phase 5 / PR1 合并主线 `3a862780`(config_version 竞争) + +- **背景:** 合并最新 `upstream/main`(`3a862780`)时只有 `config.example.yaml` 一处文本冲突: + 冲突块本身是主线新增的 `lead_prompt_overlay` 注释段(本 PR 该位置为空),取主线即可。 + 真正的语义问题是 `config_version`:merge-base 为 47,本 PR 与主线**各自** bump 到同一个 48 + (本 PR 为 `authorization` / roles 新增字段,主线为 prompt overlay 等)。该字段只驱动 + `AppConfig._check_config_version()` 的过期提示(`backend/docs/CONFIGURATION.md`),两边同号会让 + 「已从主线 48 升级过的 `config.yaml`」收不到缺 `authorization` 字段的提示。 +- **决策:** 按本文件「每次更新 PR 前的固定清单」执行:先 fetch 最新 `upstream/main`、读最新值 + (48),取下一个可用号 **49**,并在三处镜像同步(`config.example.yaml`、 + `deploy/helm/deer-flow/values.yaml`、`deploy/helm/deer-flow/README.md`); + `frontend/src/content/{en,zh}/harness/checkpoints/reference.mdx` 里「current `config_version`」 + 也一并改为 49(该文档由主线新增,写成时值为 47)。冲突块取主线;本 PR 的 `authorization:` + 段与 `peek_loaded_app_config()` 均在自动合并结果中保留。 +- **证据:** `scripts/check_config_version.sh` 通过(example=49 / chart=49); + `backend/tests/test_config_version.py` 全绿(含用真实 `scripts/config-upgrade.sh` 跑 + v26 → 当前版本的升级用例,其 `expected_version` 直接读 `config.example.yaml`)。 +- **兼容性:** 仅提示语义变化,运行时行为不变。已按主线 48 升级的 `config.yaml` 会收到一次 + “outdated” 提示,可 `make config-upgrade` 合并 `authorization` 默认段——这正是版本号存在的目的。 +- **延期:** 不变(工具链路 PR2、页面切片 PR3)。相邻且非本轮产生的文档漂移未改:上述 + checkpoints 文档里的 `appcfg:531-575` / `appcfg:570-575` 行号引用在主线自己新增 prompt overlay + 后已失准(当前 `_check_config_version` 位于 `appcfg:534-576`),不属本 PR 面内。 + ### 新记录模板 ```markdown diff --git a/frontend/src/content/en/harness/checkpoints/reference.mdx b/frontend/src/content/en/harness/checkpoints/reference.mdx index ee884741c5..8fa502f2d9 100644 --- a/frontend/src/content/en/harness/checkpoints/reference.mdx +++ b/frontend/src/content/en/harness/checkpoints/reference.mdx @@ -103,7 +103,7 @@ No environment variable selects the checkpoint mode or its cadence. Both come on ### Config version -The schema change shipped with `config_version 27`; `config.example.yaml` is currently `config_version: 47` and a missing version counts as `0` (`example:23`, `appcfg:531-575`). An outdated `config.yaml` logs: +The schema change shipped with `config_version 27`; `config.example.yaml` is currently `config_version: 49` and a missing version counts as `0` (`example:23`, `appcfg:531-575`). An outdated `config.yaml` logs: ```text Your config.yaml (version %d) is outdated — the latest version is %d. Run `make config-upgrade` to merge new fields into your config. diff --git a/frontend/src/content/en/harness/extensions/index.mdx b/frontend/src/content/en/harness/extensions/index.mdx index cd63927892..5077857764 100644 --- a/frontend/src/content/en/harness/extensions/index.mdx +++ b/frontend/src/content/en/harness/extensions/index.mdx @@ -103,7 +103,7 @@ The extension manager asks for confirmation before installing, rejects source UR ## Versioning -The contract package is versioned separately from DeerFlow, and a host exposes its version as `deerflow_extension_api.API_VERSION`. This manual covers `deerflow-extension-api` **0.2.3**. +The contract package is versioned separately from DeerFlow, and a host exposes its version as `deerflow_extension_api.API_VERSION`. This manual covers `deerflow-extension-api` **0.2.4**. - Before 1.0, a minor release may break extensions and a patch release only adds. From 1.0 on, breaking changes bump the major. - Every Protocol method has a default implementation and every optional dataclass field has a default, so additive releases do not break already-released extensions. diff --git a/frontend/src/content/en/harness/extensions/reference.mdx b/frontend/src/content/en/harness/extensions/reference.mdx index 0ddfd37a43..e2d61341ea 100644 --- a/frontend/src/content/en/harness/extensions/reference.mdx +++ b/frontend/src/content/en/harness/extensions/reference.mdx @@ -1,13 +1,13 @@ --- title: Reference -description: Every public name in deerflow-extension-api 0.2.3, grouped by topic, with signatures, fields, and defaults. Also covers the compatibility rules and the contract's version history. +description: Every public name in deerflow-extension-api 0.2.4, grouped by topic, with signatures, fields, and defaults. Also covers the compatibility rules and the contract's version history. --- import { Callout } from "nextra/components"; # Reference -This page lists every name in `deerflow_extension_api.__all__` for contract version **0.2.3**. Import them from the package root: +This page lists every name in `deerflow_extension_api.__all__` for contract version **0.2.4**. Import them from the package root: ```python from deerflow_extension_api import ExtensionRegistry, MiddlewarePlacement, Placement @@ -346,6 +346,26 @@ Return the principal if it is an administrator, otherwise raise `PermissionError `"deerflow_extension_principal_resolver"`. The `app.state` attribute the host installs its resolver under. Host-owned. +### `require_plugin_management(request: object, namespace: str, *, scope: Literal["read", "write"] = "read") -> ExtensionPrincipal` + +Return the caller when it holds `plugin_management` **scope** for an installed plugin *namespace*; otherwise raise `PermissionError`. Use it in a contributed management route before reading (`scope="read"`) or changing (`scope="write"`) the enterprise's own policy store. The host asks its configured authorization provider with the caller's trusted identity, so read and write are separate decisions and neither is implied by page access or by a tool grant. + +It fails closed: an unknown namespace, an anonymous caller, a missing or failing host resolver, and a non-allowed decision all raise `PermissionError`. A host that is running on a configuration and can no longer read it follows the configured failure policy — denied under the default `fail_closed: true`. A host with `authorization.enabled: false`, or one with no configuration at all, answers "allow", so the helper is a no-op and does not start rejecting routes; an enterprise that needs an unconditional floor keeps calling `require_admin` as well. A `scope` other than `"read"`/`"write"`, or a namespace outside `[a-z][a-z0-9_.-]{0,95}`, raises `ValueError` before any decision. + +This form is synchronous. Call it from a FastAPI `def` endpoint (executed in the thread pool), not from an async endpoint. + +### `arequire_plugin_management(request: object, namespace: str, *, scope: Literal["read", "write"] = "read") -> ExtensionPrincipal` + +The async form of `require_plugin_management`, for `async def` endpoints. It awaits the host's async resolver and never falls back to the synchronous one, so the host's configuration read stays off the event loop. + +### `EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY` + +`"deerflow_extension_plugin_authz_resolver"`. The `app.state` attribute the host installs the synchronous `plugin_management` resolver under, answering `(request, namespace, scope) -> bool | None`. Host-owned. + +### `EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY` + +`"deerflow_extension_plugin_authz_resolver_async"`. The async counterpart, answering `async (request, namespace, scope) -> bool | None`. Host-owned. + ## Message provenance Middleware that injects messages stamps them, so an observer can tell an injected message from the user's own without matching on wording. @@ -400,7 +420,7 @@ SHA-256 hex digest of `canonical_json(value)` encoded as UTF-8. ## Full-stack plugins (experimental) - The plugin contract is experimental in 0.2.3. Always check the return value of + The plugin contract is experimental in 0.2.4. Always check the return value of `registry.plugin(...)`. See [Plugins](/docs/harness/extensions/plugins). @@ -482,14 +502,14 @@ Frozen dataclass describing one non-secret deployment setting. ### `API_VERSION` -The host's contract version as a dotted string, `"0.2.3"` for the contract this page describes. Always equal to the package version in `backend/packages/extension-api/pyproject.toml`. +The host's contract version as a dotted string, `"0.2.4"` for the contract this page describes. Always equal to the package version in `backend/packages/extension-api/pyproject.toml`. ## Compatibility rules - **Additive growth.** Every Protocol method has a default implementation and every optional dataclass field has a default. A contract release that adds a method or field does not break extensions built against an earlier one. - **Before 1.0**, a minor release may break extensions and a patch release is additive. **From 1.0 on**, breaking changes bump the major. - **The `@extension(api=...)` check.** When an install function carries a marker, the host refuses it unless: - - before 1.0: the same major and minor, and the host's version is at least the declared one (a `0.2.3` host accepts `0.2.0` through `0.2.3`, and rejects `0.2.4`, `0.1.x`, and `0.3.x`); + - before 1.0: the same major and minor, and the host's version is at least the declared one (a `0.2.4` host accepts `0.2.0` through `0.2.4`, and rejects `0.2.5`, `0.1.x`, and `0.3.x`); - from 1.0: the same major, and the host's version is at least the declared one. A marker that is not a dotted numeric string is refused. An install function without a marker is not checked. @@ -507,5 +527,6 @@ The host's contract version as a dotted string, `"0.2.3"` for the contract this | 0.2.1 | [#5405](https://github.com/bytedance/deer-flow/pull/5405) | `ExtensionRuntimeDeps.run_evidence_reader` with `RunEvidenceReader`, `RunPage`, `RunEventPage`, `RunStatusView`, `RunEventView`, `InvalidRunEvidenceCursor` | | 0.2.2 | [#5647](https://github.com/bytedance/deer-flow/pull/5647) | Experimental `registry.plugin()` with `PluginContribution`, `BrowserModule`, `BackendAction`, `ModelTool`, `ActionContext`, `ToolContext`, `SettingsField` | | 0.2.3 | [#5727](https://github.com/bytedance/deer-flow/pull/5727), [#5685](https://github.com/bytedance/deer-flow/pull/5685) | Request-scoped run evidence: `resolve_run_evidence_reader`, `require_run_evidence_reader`, `RUN_EVIDENCE_READER_RESOLVER_KEY` (#5727). Packaged browser resources: `BrowserAssets`, and `PluginContribution.frontend` also accepts it (#5685) | +| 0.2.4 | [#5842](https://github.com/bytedance/deer-flow/pull/5842) | Plugin resource authorization: `require_plugin_management`, `arequire_plugin_management`, `EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY`, `EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY` | No release has removed a public name. diff --git a/frontend/src/content/zh/harness/checkpoints/reference.mdx b/frontend/src/content/zh/harness/checkpoints/reference.mdx index 7618ce9d19..b7721ec775 100644 --- a/frontend/src/content/zh/harness/checkpoints/reference.mdx +++ b/frontend/src/content/zh/harness/checkpoints/reference.mdx @@ -103,7 +103,7 @@ sharing one checkpoint database must use the same value." ### 配置版本 -该 schema 变更随 `config_version 27` 发布;`config.example.yaml` 当前是 `config_version: 47`,缺少版本号按 `0` 处理(`example:23`、`appcfg:531-575`)。过旧的 `config.yaml` 会记录: +该 schema 变更随 `config_version 27` 发布;`config.example.yaml` 当前是 `config_version: 49`,缺少版本号按 `0` 处理(`example:23`、`appcfg:531-575`)。过旧的 `config.yaml` 会记录: ```text Your config.yaml (version %d) is outdated — the latest version is %d. Run `make config-upgrade` to merge new fields into your config. diff --git a/frontend/src/content/zh/harness/extensions/index.mdx b/frontend/src/content/zh/harness/extensions/index.mdx index 2824f15de5..ef3ea7cb59 100644 --- a/frontend/src/content/zh/harness/extensions/index.mdx +++ b/frontend/src/content/zh/harness/extensions/index.mdx @@ -100,7 +100,7 @@ plugins: ## 版本 -契约包独立于 DeerFlow 进行版本管理,宿主通过 `deerflow_extension_api.API_VERSION` 暴露其版本。本手册覆盖 `deerflow-extension-api` **0.2.3**。 +契约包独立于 DeerFlow 进行版本管理,宿主通过 `deerflow_extension_api.API_VERSION` 暴露其版本。本手册覆盖 `deerflow-extension-api` **0.2.4**。 - 1.0 之前,minor 版本可以有不兼容变更,patch 版本只做增量。1.0 之后,不兼容变更会升 major。 - 每个 Protocol 方法都有默认实现,每个可选的 dataclass 字段都有默认值,所以增量发布不会破坏已经发布的扩展。 diff --git a/frontend/src/content/zh/harness/extensions/reference.mdx b/frontend/src/content/zh/harness/extensions/reference.mdx index d5dd1a45b6..997c2140b4 100644 --- a/frontend/src/content/zh/harness/extensions/reference.mdx +++ b/frontend/src/content/zh/harness/extensions/reference.mdx @@ -1,13 +1,13 @@ --- title: 参考 -description: deerflow-extension-api 0.2.3 中的每一个公开名称,按主题分组,附签名、字段和默认值。另含兼容性规则和契约的版本历史。 +description: deerflow-extension-api 0.2.4 中的每一个公开名称,按主题分组,附签名、字段和默认值。另含兼容性规则和契约的版本历史。 --- import { Callout } from "nextra/components"; # 参考 -本页列出契约版本 **0.2.3** 中 `deerflow_extension_api.__all__` 的每一个名称。请从包根导入: +本页列出契约版本 **0.2.4** 中 `deerflow_extension_api.__all__` 的每一个名称。请从包根导入: ```python from deerflow_extension_api import ExtensionRegistry, MiddlewarePlacement, Placement @@ -346,6 +346,26 @@ Gateway 的实现接受 1 到 2000 的 `limit` 和非负的 `after_seq`,否则 `"deerflow_extension_principal_resolver"`。宿主安装解析器所用的 `app.state` 属性名,由宿主拥有。 +### `require_plugin_management(request: object, namespace: str, *, scope: Literal["read", "write"] = "read") -> ExtensionPrincipal` + +当调用者对已安装插件的 *namespace* 拥有 `plugin_management` 的指定 **scope** 时返回该主体,否则抛出 `PermissionError`。在自有的管理路由里,读取(`scope="read"`)或修改(`scope="write"`)企业自己的策略存储之前调用它。宿主会用调用者的可信身份询问其配置的授权 provider,因此读和写是两个独立决策,且都不由页面访问或工具授权隐含。 + +它按失败关闭(fail closed):未知 namespace、匿名调用者、宿主解析器缺失或失败、以及非允许的决策都会抛 `PermissionError`。宿主正在运行一份配置、但当前读不到它时,走配置的失败策略——默认 `fail_closed: true` 即拒绝。当宿主 `authorization.enabled: false`,或宿主根本没有配置时,答案恒为「允许」,因此该 helper 是 no-op,不会开始拒绝路由;需要无条件底线的企业仍应同时调用 `require_admin`。`scope` 不是 `"read"`/`"write"`,或 namespace 不符合 `[a-z][a-z0-9_.-]{0,95}`,都会在任何决策之前抛 `ValueError`。 + +这是同步形式。请在 FastAPI 的 `def` 端点(在线程池中执行)中调用,不要在 async 端点中使用。 + +### `arequire_plugin_management(request: object, namespace: str, *, scope: Literal["read", "write"] = "read") -> ExtensionPrincipal` + +`require_plugin_management` 的异步形式,供 `async def` 端点使用。它 await 宿主的异步解析器,且从不回退到同步解析器,因此宿主的配置读取始终不占用事件循环。 + +### `EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY` + +`"deerflow_extension_plugin_authz_resolver"`。宿主安装同步 `plugin_management` 解析器所用的 `app.state` 属性名,签名为 `(request, namespace, scope) -> bool | None`。由宿主拥有。 + +### `EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY` + +`"deerflow_extension_plugin_authz_resolver_async"`。对应的异步解析器,签名为 `async (request, namespace, scope) -> bool | None`。由宿主拥有。 + ## 消息来源标记 注入消息的中间件会给消息打上标记,这样观察者无需匹配措辞就能区分注入的消息和用户自己的消息。 @@ -400,7 +420,7 @@ class ReleasePolicyProvider(Protocol): ## 全栈插件(实验性) - 插件契约在 0.2.3 中是实验性的。务必检查 `registry.plugin(...)` 的返回值。见[插件](/docs/harness/extensions/plugins)。 + 插件契约在 0.2.4 中是实验性的。务必检查 `registry.plugin(...)` 的返回值。见[插件](/docs/harness/extensions/plugins)。 ### `PluginContribution` @@ -481,14 +501,14 @@ class ReleasePolicyProvider(Protocol): ### `API_VERSION` -宿主的契约版本,为点分字符串,本页描述的契约是 `"0.2.3"`。始终等于 `backend/packages/extension-api/pyproject.toml` 中的包版本。 +宿主的契约版本,为点分字符串,本页描述的契约是 `"0.2.4"`。始终等于 `backend/packages/extension-api/pyproject.toml` 中的包版本。 ## 兼容性规则 - **增量演进。** 每个 Protocol 方法都有默认实现,每个可选的 dataclass 字段都有默认值。新增方法或字段的契约发布不会破坏基于更早版本构建的扩展。 - **1.0 之前**,minor 版本可以有不兼容变更,patch 版本只做增量。**1.0 之后**,不兼容变更会升 major。 - **`@extension(api=...)` 检查。** install 函数带有标记时,宿主只在以下情况接受它: - - 1.0 之前:major 和 minor 相同,且宿主版本不低于声明版本(`0.2.3` 宿主接受 `0.2.0` 到 `0.2.3`,拒绝 `0.2.4`、`0.1.x` 和 `0.3.x`); + - 1.0 之前:major 和 minor 相同,且宿主版本不低于声明版本(`0.2.4` 宿主接受 `0.2.0` 到 `0.2.4`,拒绝 `0.2.5`、`0.1.x` 和 `0.3.x`); - 1.0 之后:major 相同,且宿主版本不低于声明版本。 不是点分数字字符串的标记会被拒绝。没有标记的 install 函数不做检查。 @@ -506,5 +526,6 @@ class ReleasePolicyProvider(Protocol): | 0.2.1 | [#5405](https://github.com/bytedance/deer-flow/pull/5405) | `ExtensionRuntimeDeps.run_evidence_reader`,以及 `RunEvidenceReader`、`RunPage`、`RunEventPage`、`RunStatusView`、`RunEventView`、`InvalidRunEvidenceCursor` | | 0.2.2 | [#5647](https://github.com/bytedance/deer-flow/pull/5647) | 实验性的 `registry.plugin()`,以及 `PluginContribution`、`BrowserModule`、`BackendAction`、`ModelTool`、`ActionContext`、`ToolContext`、`SettingsField` | | 0.2.3 | [#5727](https://github.com/bytedance/deer-flow/pull/5727)、[#5685](https://github.com/bytedance/deer-flow/pull/5685) | 请求级运行证据:`resolve_run_evidence_reader`、`require_run_evidence_reader`、`RUN_EVIDENCE_READER_RESOLVER_KEY`(#5727)。打包的浏览器资源:`BrowserAssets`,`PluginContribution.frontend` 也接受它(#5685) | +| 0.2.4 | [#5842](https://github.com/bytedance/deer-flow/pull/5842) | 插件资源授权:`require_plugin_management`、`arequire_plugin_management`、`EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY`、`EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY` | 至今没有任何版本移除过公开名称。