* 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:
parent
921c17af88
commit
d4c3f98140
4 changed files with 170 additions and 0 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
106
tests/hermes_cli/test_dashboard_basic_auth_plugin_enable.py
Normal file
106
tests/hermes_cli/test_dashboard_basic_auth_plugin_enable.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue