mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(gateway): read the route authorization config off the event loop (#7557)
* fix(gateway): read the route authorization config off the event loop AuthMiddleware resolves route permissions for every authenticated request, and resolve_route_permissions read the hot-reloaded authorization config inline. get_app_config() stats and hashes config.yaml and extensions_config.json on every call and reparses them after an edit, so that file IO ran on the event loop per request. Read it through asyncio.to_thread, like the plugin authorization path already does. The context copy keeps runtime config overrides visible. * docs(changelog): reference #7557 for the off-loop route authorization config read * docs(config): note the get_app_config read cost where it is documented Move the off-loop note from gateway/AGENTS.md, which is 12 bytes under its hard limit, to the Config Caching section that documents what each get_app_config() read costs.
This commit is contained in:
parent
04dc9e5339
commit
ddfa45eaaf
@ -1397,6 +1397,13 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **authz:** Authenticated Gateway requests no longer read `config.yaml` and
|
||||
`extensions_config.json` on the event loop. Resolving route permissions
|
||||
checked both files (a `stat` and a full sha256 of each) for every request,
|
||||
and reparsed `config.yaml` there after an edit, stalling other requests and
|
||||
streams meanwhile. The read now runs in a worker, as the plugin
|
||||
authorization path already did. ([#7557])
|
||||
|
||||
- **memory:** Automatic context compaction no longer blocks the event loop
|
||||
while it saves the messages it is about to summarize to memory. With the
|
||||
mem0, Honcho or OpenViking backend, that save is a synchronous HTTP request,
|
||||
@ -13107,3 +13114,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
||||
[#7507]: https://github.com/bytedance/deer-flow/pull/7507
|
||||
[#7521]: https://github.com/bytedance/deer-flow/pull/7521
|
||||
[#7530]: https://github.com/bytedance/deer-flow/pull/7530
|
||||
[#7557]: https://github.com/bytedance/deer-flow/pull/7557
|
||||
|
||||
@ -1112,6 +1112,11 @@
|
||||
|
||||
### 修复
|
||||
|
||||
- **鉴权:** 已认证的 Gateway 请求不再在事件循环上读取 `config.yaml` 和
|
||||
`extensions_config.json`。解析路由权限时,每个请求都会检查这两个文件(各做一次
|
||||
`stat` 和完整的 sha256),编辑后还会在循环上重新解析 `config.yaml`,期间其他请求和
|
||||
流式输出都会停顿。现在这次读取与插件鉴权路径一样在工作线程中执行。([#7557])
|
||||
|
||||
- **记忆:** 自动上下文压缩在把即将被总结的消息保存到记忆时,不再阻塞事件循环。
|
||||
使用 mem0、Honcho 或 OpenViking 后端时,这次保存是同步 HTTP 请求,Gateway 在记忆
|
||||
服务器响应之前无法处理其他请求和流式输出。现在它与每轮结束后的记忆写入一样在工作
|
||||
@ -10463,3 +10468,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
||||
[#7507]: https://github.com/bytedance/deer-flow/pull/7507
|
||||
[#7521]: https://github.com/bytedance/deer-flow/pull/7521
|
||||
[#7530]: https://github.com/bytedance/deer-flow/pull/7530
|
||||
[#7557]: https://github.com/bytedance/deer-flow/pull/7557
|
||||
|
||||
@ -189,6 +189,9 @@ def _get_route_authorization_config() -> AuthorizationConfig:
|
||||
|
||||
Falls back to a disabled config when AppConfig is not available (e.g. test
|
||||
environments without a config.yaml), preserving legacy all-permissions behavior.
|
||||
``get_app_config()`` stats and hashes ``config.yaml`` and
|
||||
``extensions_config.json`` on every call, so async request paths use
|
||||
:func:`_route_authorization_config_async`.
|
||||
"""
|
||||
from deerflow.config.app_config import get_app_config
|
||||
|
||||
@ -198,6 +201,11 @@ def _get_route_authorization_config() -> AuthorizationConfig:
|
||||
return AuthorizationConfig()
|
||||
|
||||
|
||||
async def _route_authorization_config_async() -> AuthorizationConfig:
|
||||
"""Off-loop :func:`_get_route_authorization_config` for async request paths."""
|
||||
return await asyncio.to_thread(_get_route_authorization_config)
|
||||
|
||||
|
||||
# --- Provider cache (W1/F1) ---
|
||||
# Keyed by config object identity (id) so the expensive model_dump() signature
|
||||
# is only recomputed when get_app_config() returns a new object (hot-reload).
|
||||
@ -274,8 +282,10 @@ async def resolve_route_permissions(user: User, *, is_internal: bool) -> list[st
|
||||
When enabled, every registered ``resource:action`` permission is evaluated
|
||||
independently so a provider failure affects only the route being checked.
|
||||
Provider instances are cached per config signature (hot-reload safe).
|
||||
The config is read in a worker: ``AuthMiddleware`` calls this for every
|
||||
authenticated request.
|
||||
"""
|
||||
config = _get_route_authorization_config()
|
||||
config = await _route_authorization_config_async()
|
||||
if config.enabled is not True:
|
||||
return list(_ALL_PERMISSIONS)
|
||||
|
||||
|
||||
@ -68,6 +68,9 @@ working-directory changes cannot redirect it. Failed explicit loads preserve the
|
||||
prior pin; reset, injection and argument-less reload clear it. Ambient reads never
|
||||
pin a path. Runtime-scoped overrides bypass the singleton lock;
|
||||
`peek_loaded_app_config()` also returns the existing snapshot without locking.
|
||||
Each read stats and hashes `config.yaml` and `extensions_config.json`, so async
|
||||
request paths call it through `asyncio.to_thread` (Gateway route and plugin
|
||||
authorization in `app/gateway/authz.py`).
|
||||
|
||||
**Config Hot-Reload Boundary**: Gateway dependencies route through `get_app_config()` on every request, so per-run fields like `models[*].max_tokens`, `summarization.*`, `title.*`, `memory.*`, `subagents.*`, `verification.*`, `tools[*]`, and the agent system prompt pick up `config.yaml` edits on the next message. `AppConfig` is intentionally **not** cached on `app.state` — `lifespan()` keeps a local `startup_config` variable for one-shot bootstrap work and passes it to `langgraph_runtime(app, startup_config)`.
|
||||
|
||||
|
||||
@ -0,0 +1,118 @@
|
||||
"""Regression anchor: route permission resolution must read the config off the loop.
|
||||
|
||||
``AuthMiddleware`` resolves the caller's route permissions for every
|
||||
authenticated request. That reads the hot-reloaded authorization config through
|
||||
``get_app_config()``, which stats and hashes ``config.yaml`` and
|
||||
``extensions_config.json`` on every call and reparses them after an edit, so
|
||||
calling it inline put that file IO on the event loop for every request.
|
||||
|
||||
The anchor drives the real middleware against real config files, with
|
||||
authorization both disabled (the default) and enabled through the built-in RBAC
|
||||
provider. The RBAC case returns a permission subset that only the file on disk
|
||||
declares, so the read cannot be skipped without the assertion failing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from blockbuster import BlockingError
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
import app.gateway.authz as authz
|
||||
from app.gateway.auth_middleware import AuthMiddleware
|
||||
from app.gateway.authz import Permissions
|
||||
from app.gateway.internal_auth import create_internal_auth_headers
|
||||
from deerflow.config.app_config import reset_app_config
|
||||
from deerflow.config.extensions_config import reset_extensions_config
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
_SANDBOX = "sandbox:\n use: deerflow.sandbox.local:LocalSandboxProvider\n"
|
||||
_DISABLED = _SANDBOX + "authorization:\n enabled: false\n"
|
||||
_RBAC = (
|
||||
_SANDBOX
|
||||
+ """authorization:
|
||||
enabled: true
|
||||
fail_closed: true
|
||||
default_role: user
|
||||
provider:
|
||||
use: deerflow.authz.rbac:RbacAuthorizationProvider
|
||||
config:
|
||||
roles:
|
||||
user:
|
||||
routes:
|
||||
allow: ["threads:read", "runs:read"]
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def write_configs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
config_path = tmp_path / "config.yaml"
|
||||
extensions_path = tmp_path / "extensions_config.json"
|
||||
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
||||
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
||||
# Start from an empty route provider cache and restore it afterwards.
|
||||
monkeypatch.setattr(authz, "_route_provider_cache", {})
|
||||
monkeypatch.setattr(authz, "_route_provider_config_id", None)
|
||||
monkeypatch.setattr(authz, "_route_provider_config_sig", None)
|
||||
|
||||
def write(config_yaml: str) -> None:
|
||||
config_path.write_text(config_yaml, encoding="utf-8")
|
||||
extensions_path.write_text(json.dumps({"mcpServers": {}, "skills": {}}), encoding="utf-8")
|
||||
|
||||
reset_app_config()
|
||||
reset_extensions_config()
|
||||
yield write
|
||||
reset_app_config()
|
||||
reset_extensions_config()
|
||||
|
||||
|
||||
def _app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.add_middleware(AuthMiddleware)
|
||||
|
||||
@app.get("/api/probe")
|
||||
async def probe(request: Request) -> list[str]:
|
||||
return list(request.state.auth.permissions)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def _permissions(client: httpx.AsyncClient) -> list[str]:
|
||||
response = await client.get("/api/probe", headers=create_internal_auth_headers())
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_yaml", "expected"),
|
||||
[
|
||||
pytest.param(_DISABLED, list(authz._ALL_PERMISSIONS), id="disabled"),
|
||||
pytest.param(_RBAC, [Permissions.THREADS_READ, Permissions.RUNS_READ], id="rbac"),
|
||||
],
|
||||
)
|
||||
async def test_auth_middleware_reads_route_authorization_config_off_the_loop(write_configs, config_yaml: str, expected: list[str]) -> None:
|
||||
await asyncio.to_thread(write_configs, config_yaml)
|
||||
|
||||
transport = httpx.ASGITransport(app=_app())
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://gateway.test") as client:
|
||||
# The first request loads the config; the second only revalidates it,
|
||||
# which is the per-request cost.
|
||||
assert await _permissions(client) == expected
|
||||
assert await _permissions(client) == expected
|
||||
|
||||
|
||||
async def test_blocking_probe_inline_route_config_read_actually_trips_the_gate(write_configs) -> None:
|
||||
"""Meta-check: revalidating a loaded config inline on the loop is caught by the gate."""
|
||||
await asyncio.to_thread(write_configs, _RBAC)
|
||||
loaded = await asyncio.to_thread(authz._get_route_authorization_config)
|
||||
assert loaded.enabled is True
|
||||
|
||||
with pytest.raises(BlockingError):
|
||||
authz._get_route_authorization_config()
|
||||
@ -1,5 +1,6 @@
|
||||
"""Route-level authorization tests for the Gateway permission decorators."""
|
||||
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
@ -19,7 +20,9 @@ from app.gateway.authz import (
|
||||
from app.gateway.routers import runs, scheduled_tasks
|
||||
from deerflow.authz.provider import AuthzDecision, AuthzReason
|
||||
from deerflow.authz.rbac import RbacAuthorizationProvider
|
||||
from deerflow.config.app_config import AppConfig, pop_current_app_config, push_current_app_config
|
||||
from deerflow.config.authorization_config import AuthorizationConfig, AuthorizationProviderConfig
|
||||
from deerflow.config.sandbox_config import SandboxConfig
|
||||
|
||||
|
||||
class _RecordingProvider:
|
||||
@ -251,6 +254,44 @@ async def test_route_permissions_use_builtin_rbac_route_policy(monkeypatch):
|
||||
assert permissions == [Permissions.THREADS_READ, Permissions.RUNS_READ]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_route_permissions_read_config_in_a_worker_with_the_caller_context(monkeypatch):
|
||||
"""The config read runs off the loop thread and still sees a runtime config override."""
|
||||
import app.gateway.authz as authz_module
|
||||
|
||||
monkeypatch.setattr(authz_module, "_route_provider_cache", {})
|
||||
monkeypatch.setattr(authz_module, "_route_provider_config_id", None)
|
||||
monkeypatch.setattr(authz_module, "_route_provider_config_sig", None)
|
||||
read_threads: list[int] = []
|
||||
read_config = authz_module._get_route_authorization_config
|
||||
|
||||
def recording_read():
|
||||
read_threads.append(threading.get_ident())
|
||||
return read_config()
|
||||
|
||||
monkeypatch.setattr(authz_module, "_get_route_authorization_config", recording_read)
|
||||
override = AppConfig(
|
||||
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"),
|
||||
authorization=AuthorizationConfig(
|
||||
enabled=True,
|
||||
default_role="user",
|
||||
provider=AuthorizationProviderConfig(
|
||||
use="deerflow.authz.rbac:RbacAuthorizationProvider",
|
||||
config={"roles": {"user": {"routes": {"allow": [Permissions.THREADS_READ]}}}},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
push_current_app_config(override)
|
||||
try:
|
||||
permissions = await resolve_route_permissions(_user(), is_internal=False)
|
||||
finally:
|
||||
pop_current_app_config()
|
||||
|
||||
assert read_threads and threading.get_ident() not in read_threads
|
||||
assert permissions == [Permissions.THREADS_READ]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_uses_route_permission_resolution(monkeypatch):
|
||||
user = User(email="route-authz@example.com", password_hash="hash")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user