From c7f1468166f3592da3b203ec5bec38126fb457df Mon Sep 17 00:00:00 2001 From: Simone Date: Thu, 23 Jul 2026 17:29:50 +0200 Subject: [PATCH] feat(config): add [web]/[web.oidc] config + operations/audit.py (W3) --- config.example.toml | 35 +++++ src/ci_orchestrator/config.py | 81 +++++++++++ src/ci_orchestrator/operations/audit.py | 93 ++++++++++++ tests/python/test_config_web.py | 184 ++++++++++++++++++++++++ tests/python/test_operations_audit.py | 169 ++++++++++++++++++++++ 5 files changed, 562 insertions(+) create mode 100644 src/ci_orchestrator/operations/audit.py create mode 100644 tests/python/test_config_web.py create mode 100644 tests/python/test_operations_audit.py diff --git a/config.example.toml b/config.example.toml index db436cb..ea31a16 100644 --- a/config.example.toml +++ b/config.example.toml @@ -72,3 +72,38 @@ type = "workstation" enabled = true # root = "/var/lib/ci/cache/cargo" retention_days = 30 + +# --------------------------------------------------------------------- web UI +# Optional management web UI (`ci web serve`). Disabled unless `enabled = true`. +# Server-rendered dashboard for VM/host management, auth'd via Gitea OIDC. +# +# SECURITY: +# * `bind_host` defaults to 127.0.0.1 (localhost only). Only set it to a +# LAN IP explicitly and deliberately -- never "0.0.0.0" (that exposes the +# dashboard, which can kill VMs and purge caches, to every network the +# host is reachable from). +# * `session_secret_cred` and `[web.oidc] client_secret_cred` below are +# KEYRING KEY NAMES, not the secrets themselves. The actual secret +# values are never written to this file -- set them with: +# ci creds set WebSessionSecret +# ci creds set WebOidcClientSecret +# (the `_cred` values here just tell the app which keyring entry to read +# at request time). +# +# [web] +# enabled = true +# bind_host = "192.168.1.50" # explicit LAN IP; never 0.0.0.0 +# bind_port = 8800 +# audit_log = "/var/lib/ci/logs/web-audit.jsonl" +# allowed_users = ["simone"] # Gitea usernames allowed to log in +# session_secret_cred = "WebSessionSecret" # keyring KEY NAME, not the secret +# +# Gitea OIDC client registration: Gitea -> Settings -> Applications -> OAuth2, +# redirect URI must exactly match `redirect_url` below (host+port+path). +# +# [web.oidc] +# issuer = "https://gitea.lan" +# client_id = "ci-web" +# redirect_url = "http://192.168.1.50:8800/auth/callback" +# client_secret_cred = "WebOidcClientSecret" # keyring KEY NAME, not the secret +# scopes = ["openid", "profile"] diff --git a/src/ci_orchestrator/config.py b/src/ci_orchestrator/config.py index df5863e..562427b 100644 --- a/src/ci_orchestrator/config.py +++ b/src/ci_orchestrator/config.py @@ -78,6 +78,40 @@ class CargoCacheConfig: retention_days: int = 30 +@dataclass(frozen=True) +class WebOidcConfig: + """Gitea OIDC client settings for the web UI. + + ``client_secret_cred`` is a **keyring key name**, never the secret + itself — the actual client secret is set out-of-band via + ``ci creds set`` and read from :class:`~ci_orchestrator.credentials. + KeyringCredentialStore` at request time. + """ + + issuer: str + client_id: str + redirect_url: str + client_secret_cred: str = "WebOidcClientSecret" + scopes: list[str] = field(default_factory=lambda: ["openid", "profile"]) + + +@dataclass(frozen=True) +class WebConfig: + """Web UI settings (``ci web serve``). Disabled by default. + + ``session_secret_cred`` is a **keyring key name**, never the secret + itself — same pattern as :attr:`WebOidcConfig.client_secret_cred`. + """ + + enabled: bool = False + bind_host: str = "127.0.0.1" + bind_port: int = 8800 + audit_log: Path = Path("/var/lib/ci/logs/web-audit.jsonl") + allowed_users: list[str] = field(default_factory=list) + session_secret_cred: str = "WebSessionSecret" + oidc: WebOidcConfig | None = None + + @dataclass(frozen=True) class Config: """Top-level configuration object.""" @@ -89,6 +123,7 @@ class Config: guest_cred_target: str = "BuildVMGuest" ip_pool: IpPoolConfig | None = None cargo_cache: CargoCacheConfig = field(default_factory=CargoCacheConfig) + web: WebConfig = field(default_factory=WebConfig) def _platform_defaults() -> Paths: @@ -137,6 +172,45 @@ def _apply_env(paths: Paths, env: dict[str, str]) -> Paths: return _merge_paths(paths, overrides) +def _parse_web_oidc(oidc_section: dict[str, Any]) -> WebOidcConfig | None: + """Parse ``[web.oidc]``. Missing required keys degrade to ``None`` -- never raise.""" + if not oidc_section: + return None + issuer = oidc_section.get("issuer") + client_id = oidc_section.get("client_id") + redirect_url = oidc_section.get("redirect_url") + if not issuer or not client_id or not redirect_url: + return None + + scopes = oidc_section.get("scopes") + return WebOidcConfig( + issuer=str(issuer), + client_id=str(client_id), + redirect_url=str(redirect_url), + client_secret_cred=str(oidc_section.get("client_secret_cred", "WebOidcClientSecret")), + scopes=[str(s) for s in scopes] if isinstance(scopes, list) else ["openid", "profile"], + ) + + +def _parse_web_config(web_section: dict[str, Any]) -> WebConfig: + """Parse ``[web]`` (and nested ``[web.oidc]``). Absent section -> disabled defaults.""" + if not web_section: + return WebConfig() + + allowed_users = web_section.get("allowed_users") + audit_log = web_section.get("audit_log") + + return WebConfig( + enabled=bool(web_section.get("enabled", False)), + bind_host=str(web_section.get("bind_host", "127.0.0.1")), + bind_port=int(web_section.get("bind_port", 8800)), + audit_log=Path(audit_log) if audit_log else WebConfig().audit_log, + allowed_users=[str(u) for u in allowed_users] if isinstance(allowed_users, list) else [], + session_secret_cred=str(web_section.get("session_secret_cred", "WebSessionSecret")), + oidc=_parse_web_oidc(web_section.get("oidc", {}) or {}), + ) + + def load_config( config_path: Path | None = None, env: dict[str, str] | None = None, @@ -173,6 +247,7 @@ def load_config( guest_cred_target = env.get("CI_GUEST_CRED_TARGET", "BuildVMGuest") ip_pool: IpPoolConfig | None = None cargo_cache_section: dict[str, Any] = {} + web_section: dict[str, Any] = {} if toml_path and toml_path.is_file(): data = _load_toml(toml_path) @@ -203,6 +278,7 @@ def load_config( lease_file=Path(pool_section.get("lease_file") or _lease_default), ) cargo_cache_section = data.get("cargo_cache", {}) or {} + web_section = data.get("web", {}) or {} paths = _apply_env(paths, env) @@ -214,6 +290,8 @@ def load_config( retention_days=int(cargo_cache_section.get("retention_days", 30)), ) + web = _parse_web_config(web_section) + return Config( paths=paths, backend=backend, @@ -222,6 +300,7 @@ def load_config( guest_cred_target=guest_cred_target, ip_pool=ip_pool, cargo_cache=cargo_cache, + web=web, ) @@ -231,5 +310,7 @@ __all__ = [ "Config", "IpPoolConfig", "Paths", + "WebConfig", + "WebOidcConfig", "load_config", ] diff --git a/src/ci_orchestrator/operations/audit.py b/src/ci_orchestrator/operations/audit.py new file mode 100644 index 0000000..d534745 --- /dev/null +++ b/src/ci_orchestrator/operations/audit.py @@ -0,0 +1,93 @@ +"""Pure audit-trail operations shared by the CLI and (future) web UI. + +Append-only JSONL log of mutating actions (VM remove/cleanup, cache purge, +host backup, ...). Zero ``click``, zero ``print`` — this module only ever +touches the filesystem and stdlib ``json``. Presentation stays in the CLI +command wrappers and, later, the web routes. + +The log file is append-only by construction: :func:`append_audit` always +opens in append mode and never truncates, so a crash mid-write can only ever +corrupt the last line, not earlier history. :func:`read_audit` tolerates +that: malformed or torn lines are skipped rather than raising. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class AuditEntry: + """A single audit-log record for a mutating web-UI/CLI action.""" + + ts: str # ISO 8601 UTC + user: str + action: str # "vm.remove" | "vm.cleanup" | "cache.purge" | "host.backup" + target: str + result: str # "ok" | "error" | "dry-run" + detail: str + + +def append_audit(log_path: Path, entry: AuditEntry) -> None: + """Append ``entry`` as one JSON line to ``log_path``. Never truncates. + + Creates the parent directory if needed. Opens in append mode ("a"), so + existing content is always preserved. + """ + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(asdict(entry)) + "\n") + + +def _parse_line(line: str) -> AuditEntry | None: + """Parse one JSONL line into an :class:`AuditEntry`, or ``None`` if malformed.""" + stripped = line.strip() + if not stripped: + return None + try: + data = json.loads(stripped) + except json.JSONDecodeError: + return None + if not isinstance(data, dict): + return None + try: + return AuditEntry( + ts=str(data["ts"]), + user=str(data["user"]), + action=str(data["action"]), + target=str(data["target"]), + result=str(data["result"]), + detail=str(data["detail"]), + ) + except KeyError: + return None + + +def read_audit(log_path: Path, *, last: int = 100) -> list[AuditEntry]: + """Return up to the last ``last`` entries from ``log_path``, oldest-first. + + Missing file returns ``[]``. Malformed or torn lines (e.g. a crash + mid-write) are skipped rather than raising. + """ + if not log_path.is_file(): + return [] + + entries: list[AuditEntry] = [] + with log_path.open("r", encoding="utf-8") as fh: + for line in fh: + entry = _parse_line(line) + if entry is not None: + entries.append(entry) + + if last <= 0: + return [] + return entries[-last:] + + +__all__ = [ + "AuditEntry", + "append_audit", + "read_audit", +] diff --git a/tests/python/test_config_web.py b/tests/python/test_config_web.py new file mode 100644 index 0000000..ad2da9b --- /dev/null +++ b/tests/python/test_config_web.py @@ -0,0 +1,184 @@ +"""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, load_config + + +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" diff --git a/tests/python/test_operations_audit.py b/tests/python/test_operations_audit.py new file mode 100644 index 0000000..6b04185 --- /dev/null +++ b/tests/python/test_operations_audit.py @@ -0,0 +1,169 @@ +"""Tests for the pure ``operations.audit`` layer (W3 of the web-UI plan). + +Covers ``append_audit``/``read_audit`` — the append-only JSONL trail the +future web UI writes to on every mutating action. Zero ``click``/``print`` +in the module under test; only stdlib ``json`` is used. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from ci_orchestrator.operations.audit import AuditEntry, append_audit, read_audit + + +def _entry(**overrides: object) -> AuditEntry: + base: dict[str, object] = { + "ts": "2026-07-23T12:00:00+00:00", + "user": "simone", + "action": "vm.remove", + "target": "CI-build-abc123", + "result": "ok", + "detail": "removed via web UI", + } + base.update(overrides) + return AuditEntry(**base) # type: ignore[arg-type] + + +# ── append_audit / read_audit round-trip ──────────────────────────────────── + + +def test_append_then_read_round_trip(tmp_path: Path) -> None: + log_path = tmp_path / "audit" / "web-audit.jsonl" + entry = _entry() + + append_audit(log_path, entry) + result = read_audit(log_path) + + assert result == [entry] + + +def test_append_creates_parent_dir(tmp_path: Path) -> None: + log_path = tmp_path / "nested" / "dir" / "web-audit.jsonl" + + append_audit(log_path, _entry()) + + assert log_path.is_file() + + +def test_append_writes_one_json_line(tmp_path: Path) -> None: + log_path = tmp_path / "web-audit.jsonl" + entry = _entry() + + append_audit(log_path, entry) + + lines = log_path.read_text(encoding="utf-8").splitlines() + assert len(lines) == 1 + parsed = json.loads(lines[0]) + assert parsed == { + "ts": entry.ts, + "user": entry.user, + "action": entry.action, + "target": entry.target, + "result": entry.result, + "detail": entry.detail, + } + + +def test_append_never_truncates(tmp_path: Path) -> None: + log_path = tmp_path / "web-audit.jsonl" + first = _entry(target="vm-1") + second = _entry(target="vm-2") + + append_audit(log_path, first) + append_audit(log_path, second) + + result = read_audit(log_path) + assert result == [first, second] + lines = log_path.read_text(encoding="utf-8").splitlines() + assert len(lines) == 2 + + +# ── read_audit ─────────────────────────────────────────────────────────────── + + +def test_read_audit_missing_file_returns_empty_list(tmp_path: Path) -> None: + log_path = tmp_path / "does-not-exist.jsonl" + + assert read_audit(log_path) == [] + + +def test_read_audit_returns_last_n_in_file_order(tmp_path: Path) -> None: + log_path = tmp_path / "web-audit.jsonl" + entries = [_entry(target=f"vm-{i}") for i in range(5)] + for entry in entries: + append_audit(log_path, entry) + + result = read_audit(log_path, last=3) + + assert result == entries[-3:] + + +def test_read_audit_default_last_is_100(tmp_path: Path) -> None: + log_path = tmp_path / "web-audit.jsonl" + entries = [_entry(target=f"vm-{i}") for i in range(150)] + for entry in entries: + append_audit(log_path, entry) + + result = read_audit(log_path) + + assert len(result) == 100 + assert result == entries[-100:] + + +def test_read_audit_skips_malformed_lines(tmp_path: Path) -> None: + log_path = tmp_path / "web-audit.jsonl" + good = _entry(target="vm-good") + append_audit(log_path, good) + with log_path.open("a", encoding="utf-8") as fh: + fh.write("{not valid json torn line\n") + fh.write("\n") # blank line, also skipped + + result = read_audit(log_path) + + assert result == [good] + + +def test_read_audit_skips_non_object_json_line(tmp_path: Path) -> None: + log_path = tmp_path / "web-audit.jsonl" + good = _entry(target="vm-good") + append_audit(log_path, good) + with log_path.open("a", encoding="utf-8") as fh: + fh.write("[1, 2, 3]\n") # valid JSON, but not an object + fh.write('"just a string"\n') + + result = read_audit(log_path) + + assert result == [good] + + +def test_read_audit_skips_object_missing_required_key(tmp_path: Path) -> None: + log_path = tmp_path / "web-audit.jsonl" + good = _entry(target="vm-good") + append_audit(log_path, good) + with log_path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps({"ts": "x", "user": "simone"}) + "\n") # missing keys + + result = read_audit(log_path) + + assert result == [good] + + +def test_read_audit_last_zero_returns_empty_list(tmp_path: Path) -> None: + log_path = tmp_path / "web-audit.jsonl" + append_audit(log_path, _entry()) + + assert read_audit(log_path, last=0) == [] + + +def test_read_audit_skips_torn_last_line(tmp_path: Path) -> None: + log_path = tmp_path / "web-audit.jsonl" + good = _entry(target="vm-good") + append_audit(log_path, good) + with log_path.open("a", encoding="utf-8") as fh: + fh.write('{"ts": "2026-07-23T12:00:01+00:00", "user": "simone", "acti') + + result = read_audit(log_path) + + assert result == [good]