mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-18 13:46:02 +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:
@@ -51,6 +51,10 @@ class ChannelRuntimeConfigStore:
|
||||
delete=False,
|
||||
)
|
||||
try:
|
||||
try:
|
||||
Path(fd.name).chmod(0o600)
|
||||
except OSError:
|
||||
logger.debug("Unable to chmod temporary channel runtime config store at %s", fd.name, exc_info=True)
|
||||
json.dump(self._data, fd, indent=2, ensure_ascii=False)
|
||||
fd.close()
|
||||
Path(fd.name).replace(self._path)
|
||||
|
||||
@@ -10,6 +10,7 @@ import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import secrets
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from enum import IntEnum
|
||||
@@ -1376,9 +1377,29 @@ class WechatChannel(Channel):
|
||||
if self._auth_path:
|
||||
try:
|
||||
self._auth_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._auth_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
# Write through a 0o600 temp file and atomically rename so the
|
||||
# iLink bot_token is never briefly readable at umask defaults
|
||||
# (mirrors ChannelRuntimeConfigStore._save). NamedTemporaryFile
|
||||
# uses mkstemp, which creates the file at 0o600 from the start.
|
||||
fd = tempfile.NamedTemporaryFile(mode="w", dir=self._auth_path.parent, suffix=".tmp", delete=False, encoding="utf-8")
|
||||
try:
|
||||
json.dump(data, fd, ensure_ascii=False, indent=2)
|
||||
fd.close()
|
||||
Path(fd.name).replace(self._auth_path)
|
||||
except BaseException:
|
||||
fd.close()
|
||||
Path(fd.name).unlink(missing_ok=True)
|
||||
raise
|
||||
except OSError:
|
||||
logger.warning("[WeChat] failed to persist auth state to %s", self._auth_path)
|
||||
else:
|
||||
# Hardening only; the destination already inherits 0o600 from the
|
||||
# temp file. A chmod failure on filesystems without POSIX perms
|
||||
# must not masquerade as a persist failure.
|
||||
try:
|
||||
self._auth_path.chmod(0o600)
|
||||
except OSError:
|
||||
logger.debug("[WeChat] unable to chmod auth state at %s", self._auth_path, exc_info=True)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -377,6 +377,28 @@ async def get_current_user_from_request(request: Request):
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin_user(request: Request, *, detail: str) -> None:
|
||||
"""Require the authenticated caller to be an admin user.
|
||||
|
||||
``AuthMiddleware`` normally stamps ``request.state.user`` before the request
|
||||
reaches a router. Falling back to the strict dependency keeps the route safe
|
||||
in tests or alternative ASGI compositions that mount a router without the
|
||||
global middleware. ``detail`` is the route-specific 403 message.
|
||||
|
||||
Centralising this here means a future change to the admin definition (e.g.
|
||||
allowing an internal system role, adding audit logging, or switching to a
|
||||
permission-based check) lands in one place instead of drifting across the
|
||||
per-router copies that previously existed in ``mcp``, ``channel_connections``
|
||||
and ``channels``.
|
||||
"""
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is None:
|
||||
user = await get_current_user_from_request(request)
|
||||
|
||||
if getattr(user, "system_role", None) != "admin":
|
||||
raise HTTPException(status_code=403, detail=detail)
|
||||
|
||||
|
||||
async def get_optional_user_from_request(request: Request):
|
||||
"""Get optional authenticated user from request.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -124,10 +124,29 @@ Connection records live in SQL tables under `deerflow.persistence.channel_connec
|
||||
|
||||
Incoming messages that resolve to a connection carry `connection_id`, `owner_user_id`, and `workspace_id`. `ChannelManager` uses `owner_user_id` as the DeerFlow run user id and preserves the raw platform user id as `channel_user_id`.
|
||||
|
||||
Runtime provider credentials are deployment-level bot secrets, not user-owned
|
||||
connection credentials. They can come from `channels.*` in `config.yaml` or
|
||||
from the browser runtime setup flow, which persists them through
|
||||
`ChannelRuntimeConfigStore` so local/private deployments can configure bots
|
||||
without editing YAML. The runtime store is a local plaintext JSON fallback with
|
||||
owner-only file permissions (`0600`); use it only where the DeerFlow data
|
||||
directory is already trusted as secret storage. WeChat QR login auth state
|
||||
follows the same local-runtime model and may persist a QR-derived bot token in
|
||||
the channel state directory.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Browser APIs remain authenticated and CSRF-protected.
|
||||
- Connect codes are 128-bit random, short-lived, and single-use.
|
||||
- Runtime provider bot tokens are shared deployment secrets. Runtime setup
|
||||
responses mask password fields, and mutating runtime/channel-worker APIs
|
||||
require an admin user.
|
||||
- Stored per-connection credentials use the `channel_credentials` encryption
|
||||
path. If stored credential material cannot be decrypted, DeerFlow treats it
|
||||
as unavailable instead of using corrupt secrets.
|
||||
- The local plaintext runtime credential fallback is documented above; prefer
|
||||
deployment-managed environment/config secrets for non-local deployments until
|
||||
a dedicated secret backend is configured.
|
||||
- `allowed_users` is **not** a bind-time defense. Because connect codes are processed before the allowlist (see Connect Flow), anyone who possesses a valid code can consume it — not only allowlisted users. Bind security therefore rests entirely on the code's confidentiality: it is 128-bit random, expires after 10 minutes, is single-use, and is shown only in the initiating user's browser (never echoed back to chat). Treat connect codes like one-time passwords and do not forward them.
|
||||
- An external identity — `(provider, external account, workspace/team/guild)` — has at most one active owner. The most recent successful bind wins: connecting an identity that another DeerFlow user already holds transfers ownership and revokes the previous owner's binding (and its stored credentials). This is enforced at the database layer, so two users racing to bind the same identity cannot both end up connected.
|
||||
- Provider bot tokens remain in `channels.*` and are never returned to the browser.
|
||||
|
||||
@@ -17,7 +17,10 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
@@ -55,18 +58,16 @@ def _make_request(tmp_path) -> Request:
|
||||
}
|
||||
)
|
||||
app.state.channels_config = {}
|
||||
app.state.channel_connection_repo = _FakeRepo()
|
||||
# No channel_connection_repo is set: _get_repository's isinstance gate then
|
||||
# falls through to get_session_factory() (None in tests) and the handlers
|
||||
# take the repo-less 503 path. These tests only assert the store's file IO
|
||||
# is offloaded off the event loop, so the DB repo is intentionally absent.
|
||||
store = ChannelRuntimeConfigStore(tmp_path / "channels" / "runtime-config.json")
|
||||
app.state.channel_runtime_config_store = store
|
||||
user = SimpleNamespace(id=UUID("11111111-2222-3333-4444-555555555555"), system_role="admin")
|
||||
return Request({"type": "http", "app": app, "headers": [], "state": {"user": user}})
|
||||
|
||||
|
||||
class _FakeRepo:
|
||||
async def list_connections(self, owner_user_id):
|
||||
return []
|
||||
|
||||
|
||||
async def test_configure_runtime_channel_does_not_block_event_loop(tmp_path) -> None:
|
||||
request = await asyncio.to_thread(_make_request, tmp_path)
|
||||
|
||||
@@ -104,3 +105,71 @@ async def test_disconnect_runtime_channel_does_not_block_event_loop(tmp_path) ->
|
||||
"enabled": False,
|
||||
"_runtime_disabled": True,
|
||||
}
|
||||
|
||||
|
||||
async def test_runtime_config_store_file_is_owner_only(tmp_path) -> None:
|
||||
path = tmp_path / "channels" / "runtime-config.json"
|
||||
store = await asyncio.to_thread(ChannelRuntimeConfigStore, path)
|
||||
|
||||
await asyncio.to_thread(
|
||||
store.set_provider_config,
|
||||
"slack",
|
||||
{"enabled": True, "bot_token": "xoxb-ui", "app_token": "xapp-ui"},
|
||||
)
|
||||
|
||||
mode = await asyncio.to_thread(lambda: path.stat().st_mode & 0o777)
|
||||
assert mode == 0o600
|
||||
|
||||
|
||||
async def test_runtime_config_store_overwrites_loose_existing_file(tmp_path) -> None:
|
||||
"""A pre-existing world-readable file is tightened to 0o600 after a save.
|
||||
|
||||
``NamedTemporaryFile`` would yield 0o600 on a fresh path regardless of the
|
||||
code under test, so seed the destination at 0o644 first: only the store's
|
||||
atomic 0o600-temp + replace path produces an owner-only file here.
|
||||
"""
|
||||
path = tmp_path / "channels" / "runtime-config.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("{}", encoding="utf-8")
|
||||
path.chmod(0o644)
|
||||
|
||||
store = await asyncio.to_thread(ChannelRuntimeConfigStore, path)
|
||||
await asyncio.to_thread(
|
||||
store.set_provider_config,
|
||||
"slack",
|
||||
{"enabled": True, "bot_token": "xoxb-ui"},
|
||||
)
|
||||
|
||||
mode = await asyncio.to_thread(lambda: path.stat().st_mode & 0o777)
|
||||
assert mode == 0o600
|
||||
|
||||
|
||||
async def test_runtime_config_store_chmod_failure_is_logged_not_fatal(tmp_path, caplog) -> None:
|
||||
"""A chmod failure on the temp file is logged at debug and never aborts the save.
|
||||
|
||||
This is the line the previous owner-only assertion could not protect: with the
|
||||
pre-rename chmod patched to raise, the save must still persist the secret and
|
||||
the destination must still end up owner-only (via the temp file's mkstemp mode
|
||||
that ``Path.replace`` preserves). If the chmod call were dropped, the expected
|
||||
debug record would be absent and this test would fail.
|
||||
"""
|
||||
path = tmp_path / "channels" / "runtime-config.json"
|
||||
store = await asyncio.to_thread(ChannelRuntimeConfigStore, path)
|
||||
|
||||
real_chmod = Path.chmod
|
||||
|
||||
def chmod_spy(self: Path, mode: int, *args, **kwargs):
|
||||
if self.suffix == ".tmp":
|
||||
raise OSError("chmod unsupported on this filesystem")
|
||||
return real_chmod(self, mode, *args, **kwargs)
|
||||
|
||||
def _save_with_failing_temp_chmod() -> None:
|
||||
with caplog.at_level(logging.DEBUG, logger="app.channels.runtime_config_store"), mock.patch.object(Path, "chmod", chmod_spy):
|
||||
store.set_provider_config("slack", {"enabled": True, "bot_token": "xoxb-ui"})
|
||||
|
||||
await asyncio.to_thread(_save_with_failing_temp_chmod)
|
||||
|
||||
assert any("Unable to chmod temporary channel runtime config store" in record.getMessage() for record in caplog.records)
|
||||
mode = await asyncio.to_thread(lambda: path.stat().st_mode & 0o777)
|
||||
assert mode == 0o600
|
||||
assert await asyncio.to_thread(store.get_provider_config, "slack") == {"enabled": True, "bot_token": "xoxb-ui"}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Router tests for legacy IM channel management endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import UUID
|
||||
|
||||
from _router_auth_helpers import make_authed_test_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.gateway.auth.models import User
|
||||
from app.gateway.routers import channels
|
||||
|
||||
|
||||
def _admin_user() -> User:
|
||||
return User(
|
||||
id=UUID("11111111-2222-3333-4444-555555555555"),
|
||||
email="admin@example.com",
|
||||
password_hash="x",
|
||||
system_role="admin",
|
||||
)
|
||||
|
||||
|
||||
def _non_admin_user() -> User:
|
||||
return User(
|
||||
id=UUID("99999999-8888-7777-6666-555555555555"),
|
||||
email="user@example.com",
|
||||
password_hash="x",
|
||||
system_role="user",
|
||||
)
|
||||
|
||||
|
||||
def test_restart_channel_requires_admin(monkeypatch):
|
||||
service = SimpleNamespace(restart_channel=AsyncMock(return_value=True))
|
||||
monkeypatch.setattr("app.channels.service.get_channel_service", lambda: service)
|
||||
app = make_authed_test_app(user_factory=_non_admin_user)
|
||||
app.include_router(channels.router)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/api/channels/slack/restart")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "Admin privileges" in response.json()["detail"]
|
||||
service.restart_channel.assert_not_awaited()
|
||||
|
||||
|
||||
def test_restart_channel_allows_admin(monkeypatch):
|
||||
service = SimpleNamespace(restart_channel=AsyncMock(return_value=True))
|
||||
monkeypatch.setattr("app.channels.service.get_channel_service", lambda: service)
|
||||
app = make_authed_test_app(user_factory=_admin_user)
|
||||
app.include_router(channels.router)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/api/channels/slack/restart")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"success": True,
|
||||
"message": "Channel slack restarted successfully",
|
||||
}
|
||||
service.restart_channel.assert_awaited_once_with("slack")
|
||||
|
||||
|
||||
def test_get_channels_status_remains_read_only(monkeypatch):
|
||||
service = SimpleNamespace(
|
||||
get_status=lambda: {
|
||||
"service_running": True,
|
||||
"channels": {
|
||||
"slack": {
|
||||
"enabled": True,
|
||||
"running": True,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr("app.channels.service.get_channel_service", lambda: service)
|
||||
app = make_authed_test_app(user_factory=_non_admin_user)
|
||||
app.include_router(channels.router)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/channels/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["service_running"] is True
|
||||
assert response.json()["channels"]["slack"]["running"] is True
|
||||
@@ -12,15 +12,16 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.gateway.deps import require_admin_user
|
||||
from app.gateway.routers import mcp as mcp_router
|
||||
from app.gateway.routers.mcp import (
|
||||
_ADMIN_REQUIRED_DETAIL,
|
||||
_MCP_STDIO_COMMAND_ALLOWLIST_ENV,
|
||||
McpConfigUpdateRequest,
|
||||
McpOAuthConfigResponse,
|
||||
McpServerConfigResponse,
|
||||
_mask_server_config,
|
||||
_merge_preserving_secrets,
|
||||
_require_admin_user,
|
||||
_validate_mcp_update_request,
|
||||
reset_mcp_tools_cache_endpoint,
|
||||
update_mcp_configuration,
|
||||
@@ -334,10 +335,10 @@ def _request_with_role(system_role: str):
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_config_requires_admin_user():
|
||||
"""MCP config is system-level executable configuration, not a normal user setting."""
|
||||
await _require_admin_user(_request_with_role("admin"))
|
||||
await require_admin_user(_request_with_role("admin"), detail=_ADMIN_REQUIRED_DETAIL)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _require_admin_user(_request_with_role("user"))
|
||||
await require_admin_user(_request_with_role("user"), detail=_ADMIN_REQUIRED_DETAIL)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage
|
||||
@@ -1310,5 +1312,67 @@ def test_qrcode_login_binds_and_persists_auth_state(monkeypatch, tmp_path: Path)
|
||||
assert auth_state["status"] == "confirmed"
|
||||
assert auth_state["bot_token"] == "bound-token"
|
||||
assert auth_state["ilink_bot_id"] == "bot-99"
|
||||
assert ((state_dir / "wechat-auth.json").stat().st_mode & 0o777) == 0o600
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_save_auth_state_tightens_preexisting_loose_file(tmp_path: Path):
|
||||
"""A world-readable auth file is replaced by an owner-only one, atomically.
|
||||
|
||||
The bot_token must never be observable at loose permissions: the atomic
|
||||
0o600-temp + ``Path.replace`` path swaps in a fresh owner-only inode rather
|
||||
than truncating the existing 0o644 file in place. Seeding the destination at
|
||||
0o644 first means a regression back to ``write_text`` + late ``chmod`` would
|
||||
leave a detectable window (and, here, the temp-file artifact behind).
|
||||
"""
|
||||
from app.channels.wechat import WechatChannel
|
||||
|
||||
state_dir = tmp_path / "wechat-state"
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
auth_path = state_dir / "wechat-auth.json"
|
||||
auth_path.write_text(json.dumps({"status": "pending"}), encoding="utf-8")
|
||||
auth_path.chmod(0o644)
|
||||
|
||||
channel = WechatChannel(
|
||||
bus=MessageBus(),
|
||||
config={"state_dir": str(state_dir), "qrcode_login_enabled": True},
|
||||
)
|
||||
channel._save_auth_state(status="confirmed", bot_token="bound-token", ilink_bot_id="bot-1")
|
||||
|
||||
assert (auth_path.stat().st_mode & 0o777) == 0o600
|
||||
assert json.loads(auth_path.read_text(encoding="utf-8"))["bot_token"] == "bound-token"
|
||||
# Atomic write leaves no temp-file residue behind.
|
||||
assert list(state_dir.glob("*.tmp")) == []
|
||||
|
||||
|
||||
def test_save_auth_state_chmod_failure_is_logged_not_warned(tmp_path: Path, caplog):
|
||||
"""A chmod failure on a perms-less filesystem must not look like a persist failure.
|
||||
|
||||
With the post-replace chmod split into its own try/except, a chmod ``OSError``
|
||||
is logged at debug while the JSON is genuinely on disk — operators must not see
|
||||
the misleading ``failed to persist`` warning that the shared try/except produced.
|
||||
"""
|
||||
from app.channels.wechat import WechatChannel
|
||||
|
||||
state_dir = tmp_path / "wechat-state"
|
||||
channel = WechatChannel(
|
||||
bus=MessageBus(),
|
||||
config={"state_dir": str(state_dir), "qrcode_login_enabled": True},
|
||||
)
|
||||
|
||||
real_chmod = Path.chmod
|
||||
|
||||
def chmod_spy(self: Path, mode: int, *args, **kwargs):
|
||||
if self.suffix == ".json":
|
||||
raise OSError("chmod unsupported on this filesystem")
|
||||
return real_chmod(self, mode, *args, **kwargs)
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="app.channels.wechat"), mock.patch.object(Path, "chmod", chmod_spy):
|
||||
channel._save_auth_state(status="confirmed", bot_token="bound-token")
|
||||
|
||||
auth_path = state_dir / "wechat-auth.json"
|
||||
assert json.loads(auth_path.read_text(encoding="utf-8"))["bot_token"] == "bound-token"
|
||||
messages = [record.getMessage() for record in caplog.records]
|
||||
assert any("unable to chmod auth state" in message for message in messages)
|
||||
assert not any("failed to persist auth state" in message for message in messages)
|
||||
|
||||
Reference in New Issue
Block a user