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:
Hyeonsang Cho 2026-10-26 19:44:09 +09:00 committed by GitHub
parent 04dc9e5339
commit ddfa45eaaf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 187 additions and 1 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)`.

View File

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

View File

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