fix(channels): harden runtime credential management APIs (#3581)

* fix(channels): harden runtime credential management APIs

* fix(channels): address review feedback on credential hardening

Follow-up to the runtime credential-hardening pass, resolving five review
findings:

- WeChat auth persistence now writes through a 0o600 NamedTemporaryFile +
  Path.replace instead of write_text-then-chmod, so the iLink bot_token is
  never briefly readable at umask defaults (mirrors ChannelRuntimeConfigStore).
- The post-write chmod is split into its own try/except: a chmod failure on a
  filesystem without POSIX perms now logs at debug instead of masquerading as
  a "failed to persist" warning.
- Extracted the three near-identical _require_admin_user helpers (mcp,
  channel_connections, channels) into a single require_admin_user(request, *,
  detail) in app/gateway/deps.py; each router supplies its own detail string.
- Strengthened the runtime-config-store chmod coverage: a new test injects a
  temp-file chmod failure and asserts it is logged at debug while the
  destination is still owner-only (mutation-verified to fail if the chmod is
  dropped), plus a loose-pre-existing-file case.
- Removed the unused _FakeRepo from the blocking-io test: its isinstance gate
  routes through the repo-less 503 path, so neither stub was ever invoked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Nan Gao
2026-06-18 04:45:33 +02:00
committed by GitHub
parent 68ba4198b8
commit 2b301e8211
11 changed files with 314 additions and 56 deletions
@@ -16,6 +16,7 @@ from app.channels.runtime_config_store import (
apply_runtime_connection_config,
merge_runtime_channel_configs,
)
from app.gateway.deps import require_admin_user
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import get_session_factory
@@ -26,6 +27,7 @@ logger = logging.getLogger(__name__)
_STATE_TTL_SECONDS = 600
_MAX_PENDING_CONNECT_CODES_PER_PROVIDER = 5
_MASKED_CREDENTIAL_VALUE = "********"
_ADMIN_REQUIRED_DETAIL = "Admin privileges required to manage channel runtime credentials."
class ChannelCredentialFieldResponse(BaseModel):
@@ -135,24 +137,6 @@ def _get_user_id(request: Request) -> str:
return str(user.id)
async def _require_admin_user(request: Request) -> None:
"""Require an admin caller for instance-wide channel runtime mutations.
Runtime credentials and the channel workers they start/stop are shared by
every user of the deployment, so only admins may change them (same model
as the MCP config API). Auth-disabled local mode uses a synthetic admin
user and is unaffected.
"""
user = getattr(request.state, "user", None)
if user is None:
from app.gateway.deps import get_current_user_from_request
user = await get_current_user_from_request(request)
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="Admin privileges required to manage channel runtime credentials.")
def _get_app_config():
from deerflow.config.app_config import get_app_config
@@ -572,7 +556,7 @@ async def disconnect_channel_connection(connection_id: str, request: Request) ->
@router.delete("/{provider}/runtime-config", response_model=ChannelProviderResponse)
async def disconnect_channel_provider_runtime(provider: str, request: Request) -> ChannelProviderResponse:
await _require_admin_user(request)
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
config = await _get_channel_connections_config(request)
if not config.enabled:
raise HTTPException(status_code=400, detail="Channel connections are disabled")
@@ -658,7 +642,7 @@ async def configure_channel_provider_runtime(
body: ChannelRuntimeConfigRequest,
request: Request,
) -> ChannelProviderResponse:
await _require_admin_user(request)
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
config = await _get_channel_connections_config(request)
if not config.enabled:
raise HTTPException(status_code=400, detail="Channel connections are disabled")
+8 -2
View File
@@ -4,13 +4,17 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from app.gateway.deps import require_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/channels", tags=["channels"])
_ADMIN_REQUIRED_DETAIL = "Admin privileges required to manage channel runtime workers."
class ChannelStatusResponse(BaseModel):
service_running: bool
@@ -35,8 +39,10 @@ async def get_channels_status() -> ChannelStatusResponse:
@router.post("/{name}/restart", response_model=ChannelRestartResponse)
async def restart_channel(name: str) -> ChannelRestartResponse:
async def restart_channel(name: str, request: Request) -> ChannelRestartResponse:
"""Restart a specific IM channel."""
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
from app.channels.service import get_channel_service
service = get_channel_service()
+6 -24
View File
@@ -7,12 +7,15 @@ from typing import Literal
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field
from app.gateway.deps import require_admin_user
from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config
from deerflow.mcp.cache import reset_mcp_tools_cache
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["mcp"])
_ADMIN_REQUIRED_DETAIL = "Admin privileges required to manage MCP configuration."
_MCP_STDIO_COMMAND_ALLOWLIST_ENV = "DEER_FLOW_MCP_STDIO_COMMAND_ALLOWLIST"
_DEFAULT_MCP_STDIO_COMMAND_ALLOWLIST = frozenset({"npx", "uvx"})
@@ -80,27 +83,6 @@ class McpCacheResetResponse(BaseModel):
_MASKED_VALUE = "***"
async def _require_admin_user(request: Request) -> None:
"""Require the authenticated caller to be an admin user.
``AuthMiddleware`` normally stamps ``request.state.user`` before the
request reaches this router. Falling back to the strict dependency keeps
this route safe even in tests or alternative ASGI compositions that mount
the router without the global middleware.
"""
user = getattr(request.state, "user", None)
if user is None:
from app.gateway.deps import get_current_user_from_request
user = await get_current_user_from_request(request)
if getattr(user, "system_role", None) != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required to manage MCP configuration.",
)
def _allowed_stdio_commands() -> set[str]:
"""Return executable names allowed for API-managed stdio MCP servers."""
raw = os.environ.get(_MCP_STDIO_COMMAND_ALLOWLIST_ENV)
@@ -269,7 +251,7 @@ async def get_mcp_configuration(request: Request) -> McpConfigResponse:
}
```
"""
await _require_admin_user(request)
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
config = get_extensions_config()
@@ -290,7 +272,7 @@ async def reset_mcp_tools_cache_endpoint(request: Request) -> McpCacheResetRespo
servers. This affects all threads and users in the current Gateway process,
and avoids relying on extensions_config.json mtime changes.
"""
await _require_admin_user(request)
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
reset_mcp_tools_cache()
return McpCacheResetResponse(
success=True,
@@ -337,7 +319,7 @@ async def update_mcp_configuration(request: Request, body: McpConfigUpdateReques
```
"""
try:
await _require_admin_user(request)
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
_validate_mcp_update_request(body)
# Get the current config path (or determine where to save it)