mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-18 21:55:59 +00:00
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:
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user