Files
Simone e56eae95c7 feat(web): service management panel over a sudo allow-list (stage D)
Adds operations/services.py: a fixed ManagedService allow-list (act-runner, act-runner-rust, vmware-host, ci-web) with find_service/service_states/control_service. control_service builds an argument list only (never shell=True), validates the action against ALLOWED_ACTIONS (ValueError->400), refuses stop on restart-only services (ci-web, allow_stop=False) without building a command, and defers ci-web's own restart via 'sudo -n systemd-run --on-active=2 systemctl restart ci-web.service' so the HTTP response returns first.

Route POST /services/{service_id}/{action} in web/routes.py mirrors vms_remove: require_user + require_csrf + two-step confirm gate + one audit entry per stage; unknown id / bad action / disallowed-stop short-circuit to 400 before any subprocess or audit; the vmware-host confirm step warns with the active-VM count. New /services read partial with state-aware Start/Stop/Restart buttons, polled every 15s.

deploy/sudoers.d/ci-web grants ci-runner exact-command NOPASSWD for exactly these nine systemctl lines plus the systemd-run deferral — no wildcards, no bare systemctl, no ci-web stop; setup-web-linux.sh installs it mode 0440 and validates with 'visudo -cf', rolling back on failure. Config gains [web].managed_services override; docs/WEB-UI.md documents the panel + security model.

Gates: 1044 tests pass (46 new security/unit tests), coverage 94.20%, ruff + mypy --strict clean. Not yet deployed: sudoers install + prod-venv reinstall + ci-web restart are a human privilege-boundary step.
2026-07-24 12:11:36 +02:00

340 lines
12 KiB
Python

"""Tests for the ``[web]`` / ``[web.oidc]`` config section (W3 of the web-UI plan).
Contract: ``[web]`` absent means the web UI is disabled by default; secrets
are NEVER read from TOML — only keyring key *names* (``client_secret_cred``,
``session_secret_cred``).
"""
from __future__ import annotations
from pathlib import Path
from ci_orchestrator.config import WebConfig, WebOidcConfig, gitea_base_url, load_config
from ci_orchestrator.operations.services import MANAGED_SERVICES, ManagedService
def _paths_section() -> str:
return (
'[paths]\nroot = "/var/lib/ci"\ntemplates = "/var/lib/ci/templates"\n'
'build_vms = "/var/lib/ci/build-vms"\nartifacts = "/var/lib/ci/artifacts"\n'
'keys = "/var/lib/ci/keys"\n'
)
def _write_toml(tmp_path: Path, extra: str = "") -> Path:
toml = tmp_path / "config.toml"
toml.write_text(_paths_section() + extra, encoding="utf-8")
return toml
# ── [web] absent ─────────────────────────────────────────────────────────────
def test_web_absent_defaults_to_disabled(tmp_path: Path) -> None:
cfg = load_config(config_path=_write_toml(tmp_path), env={})
assert cfg.web == WebConfig()
assert cfg.web.enabled is False
assert cfg.web.oidc is None
def test_web_absent_when_no_toml_file_at_all(tmp_path: Path) -> None:
# No config file at all -> pure defaults.
cfg = load_config(config_path=tmp_path / "nope.toml", env={})
assert cfg.web.enabled is False
assert cfg.web.oidc is None
# ── [web] present ────────────────────────────────────────────────────────────
def test_web_section_populates_fields(tmp_path: Path) -> None:
extra = (
"\n[web]\n"
"enabled = true\n"
'bind_host = "192.168.1.50"\n'
"bind_port = 9000\n"
'audit_log = "/var/lib/ci/logs/custom-audit.jsonl"\n'
'allowed_users = ["simone", "alice"]\n'
'session_secret_cred = "MySessionCred"\n'
)
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert cfg.web.enabled is True
assert cfg.web.bind_host == "192.168.1.50"
assert cfg.web.bind_port == 9000
assert cfg.web.audit_log == Path("/var/lib/ci/logs/custom-audit.jsonl")
assert cfg.web.allowed_users == ["simone", "alice"]
assert cfg.web.session_secret_cred == "MySessionCred"
assert cfg.web.oidc is None
def test_web_section_partial_uses_defaults_for_rest(tmp_path: Path) -> None:
extra = "\n[web]\nenabled = true\n"
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert cfg.web.enabled is True
assert cfg.web.bind_host == "127.0.0.1"
assert cfg.web.bind_port == 8800
assert cfg.web.audit_log == Path("/var/lib/ci/logs/web-audit.jsonl")
assert cfg.web.allowed_users == []
assert cfg.web.session_secret_cred == "WebSessionSecret"
# ── [web.oidc] ───────────────────────────────────────────────────────────────
def test_web_oidc_section_populates_fields(tmp_path: Path) -> None:
extra = (
"\n[web]\nenabled = true\n"
"\n[web.oidc]\n"
'issuer = "https://gitea.lan"\n'
'client_id = "ci-web"\n'
'redirect_url = "http://192.168.1.50:8800/auth/callback"\n'
'client_secret_cred = "MyOidcCred"\n'
'scopes = ["openid", "profile", "email"]\n'
)
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert cfg.web.oidc == WebOidcConfig(
issuer="https://gitea.lan",
client_id="ci-web",
redirect_url="http://192.168.1.50:8800/auth/callback",
client_secret_cred="MyOidcCred",
scopes=["openid", "profile", "email"],
)
def test_web_oidc_section_defaults_client_secret_cred_and_scopes(tmp_path: Path) -> None:
extra = (
"\n[web.oidc]\n"
'issuer = "https://gitea.lan"\n'
'client_id = "ci-web"\n'
'redirect_url = "http://192.168.1.50:8800/auth/callback"\n'
)
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert cfg.web.oidc is not None
assert cfg.web.oidc.client_secret_cred == "WebOidcClientSecret"
assert cfg.web.oidc.scopes == ["openid", "profile"]
def test_web_oidc_missing_required_key_yields_none_not_crash(tmp_path: Path) -> None:
# Missing redirect_url -> oidc must degrade to None, never raise.
extra = (
"\n[web.oidc]\n"
'issuer = "https://gitea.lan"\n'
'client_id = "ci-web"\n'
)
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert cfg.web.oidc is None
def test_web_oidc_empty_section_yields_none(tmp_path: Path) -> None:
extra = "\n[web.oidc]\n"
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert cfg.web.oidc is None
# ── security: no secret values ever stored ──────────────────────────────────
def test_no_secret_value_fields_exist_on_web_config() -> None:
"""Only keyring key *names* are stored -- never a secret value."""
field_names = set(WebConfig.__dataclass_fields__)
assert "session_secret" not in field_names
assert "session_secret_value" not in field_names
assert "session_secret_cred" in field_names
def test_no_secret_value_fields_exist_on_web_oidc_config() -> None:
field_names = set(WebOidcConfig.__dataclass_fields__)
assert "client_secret" not in field_names
assert "client_secret_value" not in field_names
assert "client_secret_cred" in field_names
def test_toml_secret_looking_value_is_never_surfaced_as_a_secret(tmp_path: Path) -> None:
"""Even if a TOML author mistakenly writes a secret-shaped value under
``client_secret_cred``/``session_secret_cred``, config.py only ever
treats it as a keyring key *name* -- there is no code path that reads a
``client_secret``/``session_secret`` key from TOML at all.
"""
extra = (
"\n[web]\nenabled = true\n"
'session_secret_cred = "sk-not-a-real-secret-just-a-name"\n'
"\n[web.oidc]\n"
'issuer = "https://gitea.lan"\n'
'client_id = "ci-web"\n'
'redirect_url = "http://192.168.1.50:8800/auth/callback"\n'
'client_secret_cred = "another-cred-name"\n'
# If config.py ever started reading these, this test would need to
# change -- their presence here proves they are ignored.
'client_secret = "should-never-be-read"\n'
)
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert cfg.web.session_secret_cred == "sk-not-a-real-secret-just-a-name"
assert cfg.web.oidc is not None
assert cfg.web.oidc.client_secret_cred == "another-cred-name"
# No attribute on WebOidcConfig can ever hold "should-never-be-read".
for value in vars(cfg.web.oidc).values():
assert value != "should-never-be-read"
# ── [web].gitea_url (Stage C2) ───────────────────────────────────────────────
def test_gitea_url_absent_from_toml_defaults_to_none() -> None:
assert WebConfig().gitea_url is None
def test_gitea_url_explicit_toml_value_populates_field(tmp_path: Path) -> None:
extra = '\n[web]\ngitea_url = "https://gitea.example.lan"\n'
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert cfg.web.gitea_url == "https://gitea.example.lan"
def test_gitea_base_url_explicit_override_wins_over_issuer(tmp_path: Path) -> None:
extra = (
"\n[web]\n"
'gitea_url = "https://gitea.explicit.lan"\n'
"\n[web.oidc]\n"
'issuer = "https://gitea.emulab.it"\n'
'client_id = "ci-web"\n'
'redirect_url = "http://192.168.1.50:8800/auth/callback"\n'
)
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert gitea_base_url(cfg.web) == "https://gitea.explicit.lan"
def test_gitea_base_url_derives_origin_from_oidc_issuer(tmp_path: Path) -> None:
extra = (
"\n[web.oidc]\n"
'issuer = "https://gitea.emulab.it"\n'
'client_id = "ci-web"\n'
'redirect_url = "http://192.168.1.50:8800/auth/callback"\n'
)
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert cfg.web.gitea_url is None # not set explicitly
assert gitea_base_url(cfg.web) == "https://gitea.emulab.it"
def test_gitea_base_url_strips_issuer_path_and_query() -> None:
web = WebConfig(
oidc=WebOidcConfig(
issuer="https://gitea.emulab.it/some/path?query=1",
client_id="ci-web",
redirect_url="http://127.0.0.1:8800/auth/callback",
)
)
assert gitea_base_url(web) == "https://gitea.emulab.it"
def test_gitea_base_url_none_when_oidc_unset_and_no_explicit_url() -> None:
assert gitea_base_url(WebConfig()) is None
def test_gitea_base_url_none_when_issuer_unparseable() -> None:
web = WebConfig(
oidc=WebOidcConfig(
issuer="not-a-url",
client_id="ci-web",
redirect_url="http://127.0.0.1:8800/auth/callback",
)
)
assert gitea_base_url(web) is None
# ── [web].managed_services (Stage D) ────────────────────────────────────────
def test_managed_services_absent_defaults_to_builtin_allowlist() -> None:
assert WebConfig().managed_services == MANAGED_SERVICES
def test_managed_services_absent_from_toml_defaults_to_builtin_allowlist(tmp_path: Path) -> None:
cfg = load_config(config_path=_write_toml(tmp_path), env={})
assert cfg.web.managed_services == MANAGED_SERVICES
def test_managed_services_override_parses_rows(tmp_path: Path) -> None:
extra = (
"\n[[web.managed_services]]\n"
'id = "act-runner"\n'
'unit = "act-runner.service"\n'
'label = "Runner"\n'
"allow_stop = true\n"
"\n[[web.managed_services]]\n"
'id = "ci-web"\n'
'unit = "ci-web.service"\n'
'label = "Web UI"\n'
"allow_stop = false\n"
)
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert cfg.web.managed_services == (
ManagedService("act-runner", "act-runner.service", "Runner", True),
ManagedService("ci-web", "ci-web.service", "Web UI", False),
)
def test_managed_services_malformed_row_is_skipped_not_raised(tmp_path: Path) -> None:
extra = (
"\n[[web.managed_services]]\n"
'id = "act-runner"\n'
'unit = "act-runner.service"\n'
'label = "Runner"\n'
"allow_stop = true\n"
"\n[[web.managed_services]]\n"
# Missing 'unit' -- this row must be skipped, not crash config loading.
'id = "broken"\n'
'label = "Broken"\n'
"allow_stop = true\n"
)
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert cfg.web.managed_services == (
ManagedService("act-runner", "act-runner.service", "Runner", True),
)
def test_managed_services_all_rows_malformed_falls_back_to_default(tmp_path: Path) -> None:
extra = (
"\n[[web.managed_services]]\n"
'id = "broken"\n'
"allow_stop = true\n"
)
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert cfg.web.managed_services == MANAGED_SERVICES
def test_managed_services_wrong_type_for_allow_stop_is_skipped(tmp_path: Path) -> None:
extra = (
"\n[[web.managed_services]]\n"
'id = "act-runner"\n'
'unit = "act-runner.service"\n'
'label = "Runner"\n'
'allow_stop = "yes"\n' # string, not bool -- must be rejected
)
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert cfg.web.managed_services == MANAGED_SERVICES
def test_managed_services_empty_list_falls_back_to_default(tmp_path: Path) -> None:
extra = "\n[web]\nmanaged_services = []\n"
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert cfg.web.managed_services == MANAGED_SERVICES