fix(dashboard): unblock basic auth plugin when setting password interactively (#54489) (#63786)

* fix(dashboard): unblock basic auth plugin during interactive password setup

When the dashboard prompts for username/password on a non-loopback bind,
also remove the bundled basic provider from plugins.disabled so
discover_plugins(force=True) can register it (#54489).

* test(dashboard): cover basic auth plugin blocked by plugins.disabled

Regression harness for #54489: credentials in config are not enough when
the bundled basic provider is on the deny-list.
This commit is contained in:
xxxigm 2026-07-17 06:49:39 +07:00 committed by GitHub
parent 921c17af88
commit d4c3f98140
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 170 additions and 0 deletions

View file

@ -12259,6 +12259,7 @@ def _maybe_setup_dashboard_auth_interactively(args) -> None:
try:
from hermes_cli.config import load_config, save_config
from hermes_cli.plugins_cmd import ensure_basic_auth_plugin_enabled_in_config
cfg = load_config()
dash = cfg.setdefault("dashboard", {})
@ -12269,6 +12270,16 @@ def _maybe_setup_dashboard_auth_interactively(args) -> None:
basic["password"] = ""
if not str(basic.get("secret", "") or "").strip():
basic["secret"] = secret
# The bundled basic provider is a backend plugin that still honours
# plugins.disabled. Unblock it when we just wrote basic_auth so the
# discover_plugins(force=True) call below can register the provider
# (#54489). Surface the mutation so an operator who deliberately
# disabled it isn't surprised.
if ensure_basic_auth_plugin_enabled_in_config(cfg):
print(
" ✓ Re-enabled the bundled 'basic' auth plugin "
"(was in plugins.disabled)"
)
save_config(cfg)
except Exception as exc:
print(f" ✗ Failed to write config.yaml: {exc}")

View file

@ -715,6 +715,33 @@ def _save_disabled_set(disabled: set) -> None:
save_config(config)
_BASIC_AUTH_PLUGIN_KEYS = frozenset({"basic", "dashboard_auth/basic"})
def ensure_basic_auth_plugin_enabled_in_config(cfg: dict) -> bool:
"""Re-enable the bundled basic dashboard-auth plugin in *cfg*.
``hermes setup`` / ``hermes plugins disable basic`` can park the plugin
in ``plugins.disabled`` while ``dashboard.basic_auth`` is configured.
The basic provider is a bundled backend that still respects the
deny-list, so password auth silently fails until the block is removed.
Returns True when ``plugins.disabled`` was modified.
"""
plugins_cfg = cfg.get("plugins")
if not isinstance(plugins_cfg, dict):
return False
disabled = plugins_cfg.get("disabled")
if not isinstance(disabled, list):
return False
if not (set(disabled) & _BASIC_AUTH_PLUGIN_KEYS):
return False
plugins_cfg["disabled"] = sorted(
set(disabled) - _BASIC_AUTH_PLUGIN_KEYS
)
return True
def _get_enabled_set() -> set:
"""Read the enabled plugins allow-list from config.yaml.

View file

@ -17768,6 +17768,32 @@ def start_server(
"There is no unauthenticated public-bind option — to keep it "
"local, bind 127.0.0.1 and tunnel in (SSH / Tailscale)."
)
# Hint when credentials exist but the bundled provider is blocked
# (#54489).
try:
from hermes_cli.config import load_config as _load_cfg
from hermes_cli.plugins_cmd import _BASIC_AUTH_PLUGIN_KEYS
_cfg = _load_cfg()
_ba = (_cfg.get("dashboard") or {}).get("basic_auth") or {}
_disabled = (_cfg.get("plugins") or {}).get("disabled") or []
# Basic auth only activates with a username AND a credential
# (plaintext password or password_hash); don't fire the hint on
# a half-configured block.
_has_creds = bool(_ba.get("username")) and bool(
_ba.get("password_hash") or _ba.get("password")
)
if _has_creds and (set(_disabled) & _BASIC_AUTH_PLUGIN_KEYS):
_fix_hint = (
"The 'basic' dashboard-auth plugin is in "
"plugins.disabled but dashboard.basic_auth is "
"configured.\n"
"Remove 'basic' from plugins.disabled (or run "
"`hermes plugins enable basic`), then restart the "
"dashboard.\n\n"
) + _fix_hint
except Exception:
pass
if skip_reasons:
raise SystemExit(
f"Refusing to bind dashboard to {host} — the auth gate "

View file

@ -0,0 +1,106 @@
"""Regression tests for dashboard basic-auth plugin enablement (#54489).
When ``dashboard.basic_auth`` is configured but the bundled ``basic``
provider plugin is listed in ``plugins.disabled``, plugin discovery skips
it and the dashboard auth gate sees zero providers even after the
interactive username/password setup path writes credentials to config.yaml.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
import yaml
from hermes_cli.dashboard_auth import clear_providers, list_providers
from hermes_cli.plugins import PluginManager, discover_plugins
from hermes_cli.plugins_cmd import ensure_basic_auth_plugin_enabled_in_config
import plugins.dashboard_auth.basic as basic_plugin
@pytest.fixture(autouse=True)
def _reset_auth_registry():
clear_providers()
yield
clear_providers()
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
return home
def _write_config(home, cfg: dict) -> None:
(home / "config.yaml").write_text(yaml.safe_dump(cfg), encoding="utf-8")
class TestEnsureBasicAuthPluginEnabled:
def test_noop_when_not_disabled(self):
cfg = {"plugins": {"disabled": ["other-plugin"]}}
assert ensure_basic_auth_plugin_enabled_in_config(cfg) is False
def test_removes_bare_basic_key(self):
cfg = {"plugins": {"disabled": ["basic", "foo"]}}
assert ensure_basic_auth_plugin_enabled_in_config(cfg) is True
assert cfg["plugins"]["disabled"] == ["foo"]
def test_removes_namespaced_key(self):
cfg = {"plugins": {"disabled": ["dashboard_auth/basic"]}}
assert ensure_basic_auth_plugin_enabled_in_config(cfg) is True
assert cfg["plugins"]["disabled"] == []
class TestBasicProviderLoadsAfterUnblock:
def test_disabled_basic_blocks_registration(self, hermes_home, monkeypatch):
password_hash = basic_plugin.hash_password("hunter2")
_write_config(
hermes_home,
{
"dashboard": {
"basic_auth": {
"username": "admin",
"password_hash": password_hash,
"secret": "a" * 32,
}
},
"plugins": {"disabled": ["basic"]},
},
)
import hermes_cli.plugins as plugins_mod
with patch.object(plugins_mod, "_plugin_manager", None):
discover_plugins(force=True)
assert list_providers() == []
def test_unblock_then_rediscover_registers_provider(
self, hermes_home, monkeypatch,
):
password_hash = basic_plugin.hash_password("hunter2")
cfg = {
"dashboard": {
"basic_auth": {
"username": "admin",
"password_hash": password_hash,
"secret": "a" * 32,
}
},
"plugins": {"disabled": ["basic"]},
}
_write_config(hermes_home, cfg)
assert ensure_basic_auth_plugin_enabled_in_config(cfg) is True
_write_config(hermes_home, cfg)
import hermes_cli.plugins as plugins_mod
with patch.object(plugins_mod, "_plugin_manager", None):
discover_plugins(force=True)
providers = list_providers()
assert len(providers) == 1
assert providers[0].name == "basic"