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