feat(config): add [web]/[web.oidc] config + operations/audit.py (W3)

This commit is contained in:
2026-07-23 17:29:50 +02:00
parent c5073580c6
commit c7f1468166
5 changed files with 562 additions and 0 deletions
+81
View File
@@ -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",
]
+93
View File
@@ -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",
]