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