feat(web): mutating actions with confirm gate, CSRF, audit (W6)

Adds the four destructive routes (vm.remove, vm.cleanup, cache.purge,
host.backup) plus a read-only /audit view, all behind require_user and
a synchronizer-token CSRF dependency (web/security.py). Every mutating
POST is a two-step confirm gate: a first POST always dry-runs the
underlying operations/ call and renders a confirm partial; only a
stage=execute POST performs the real action. Both stages write one
audit entry via operations/audit.py.

VM remove additionally validates that the target VMX resolves inside
config.paths.build_vms before touching anything (400 otherwise), and
vm.remove/vm.cleanup refuse to run (409) when a fresh vm-start.lock is
present, guarding against racing a live job.

SessionMiddleware is now installed unconditionally in create_app (was
previously only added when OIDC auth was active) since CSRF needs
request.session in both auth modes; the session secret stays
keyring-sourced when OIDC is active and falls back to a per-process
ephemeral secret in open mode. Adds python-multipart to the [web]
extra (required by FastAPI/Starlette form parsing, including plain
url-encoded forms, in the installed starlette version).
This commit is contained in:
2026-07-23 18:15:54 +02:00
parent cd2dc9cb6a
commit 45670394b9
14 changed files with 1496 additions and 33 deletions
+53 -11
View File
@@ -1,4 +1,4 @@
"""FastAPI application factory for the web dashboard (W4 read-only + W5 auth).
"""FastAPI application factory for the web dashboard (W4 read-only + W5 auth + W6 actions).
**In-process only**: routes call ``operations/`` + ``backends.load_backend``
directly and render Jinja2 templates. They never shell out to the CLI (see
@@ -11,6 +11,16 @@ localhost dev), ``require_user`` is a no-op and these routes behave exactly
as in W4. ``create_app`` itself never chooses the bind address, that is
``commands/web.py``'s job.
**Sessions are ALWAYS on (W6)**: ``SessionMiddleware`` is installed
unconditionally, not just when OIDC is active -- CSRF (``web/security.py``)
needs ``request.session`` in both auth modes. When OIDC is active the
session is signed with the keyring-sourced, persistent
``config.web.session_secret_cred`` secret (stable across restarts, so a
login survives a redeploy). When inactive, the secret is a fresh
``secrets.token_urlsafe(32)`` generated per process -- fine for an
open-localhost dev session (it only needs to survive one process's
lifetime; no keyring lookup, so open mode still needs no keyring at all).
**Backend may be unavailable**: :func:`get_backend` is a FastAPI dependency
that swallows :class:`~ci_orchestrator.backends.errors.BackendError` (raised
when ``vmrun`` is missing on this host) and degrades to ``None`` so a route
@@ -19,6 +29,7 @@ never 500s. Tests override it via ``app.dependency_overrides[get_backend]``.
from __future__ import annotations
import secrets
from pathlib import Path
from typing import TYPE_CHECKING
@@ -26,13 +37,15 @@ from fastapi import Depends, FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from ci_orchestrator.backends import load_backend
from ci_orchestrator.backends.errors import BackendError
from ci_orchestrator.operations.cache import CacheStatus, cargo_cache_status
from ci_orchestrator.operations.host import DiskStatus, RunnerStatus, disk_status, runner_status
from ci_orchestrator.operations.vms import LiveVm, list_live_vms
from ci_orchestrator.web.auth import configure_auth, require_user
from ci_orchestrator.web.auth import _read_secret, configure_auth, require_user
from ci_orchestrator.web.security import issue_csrf
if TYPE_CHECKING: # pragma: no cover
from ci_orchestrator.backends.protocol import VmBackend
@@ -120,6 +133,7 @@ def dashboard(
"cache": _fetch_cache_status(config),
"disk": _fetch_disk_status(config),
"runners": _fetch_runner_status(),
"csrf_token": issue_csrf(request),
}
return templates.TemplateResponse(request, "dashboard.html", context)
@@ -133,6 +147,7 @@ def vms_partial(
context = {
"vms": _fetch_live_vms(backend, config),
"backend_available": backend is not None,
"csrf_token": issue_csrf(request),
}
return templates.TemplateResponse(request, "partials/vms.html", context)
@@ -141,7 +156,7 @@ def cache_partial(request: Request) -> HTMLResponse:
"""``GET /cache`` -- cargo cache status partial."""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
context = {"cache": _fetch_cache_status(config)}
context = {"cache": _fetch_cache_status(config), "csrf_token": issue_csrf(request)}
return templates.TemplateResponse(request, "partials/cache.html", context)
@@ -152,6 +167,7 @@ def host_partial(request: Request) -> HTMLResponse:
context = {
"disk": _fetch_disk_status(config),
"runners": _fetch_runner_status(),
"csrf_token": issue_csrf(request),
}
return templates.TemplateResponse(request, "partials/host.html", context)
@@ -160,20 +176,26 @@ def host_partial(request: Request) -> HTMLResponse:
def create_app(config: Config) -> FastAPI:
"""Build the dashboard app.
"""Build the dashboard app: W4 read-only routes + W5 auth + W6 mutating actions.
Read-only routes only (mutating actions are W6). Binding to a concrete
address (always ``127.0.0.1`` by default -- never ``0.0.0.0``) is
``commands/web.py``'s job, not this factory's.
Binding to a concrete address (always ``127.0.0.1`` by default -- never
``0.0.0.0``) is ``commands/web.py``'s job, not this factory's.
All data routes are guarded by ``require_user`` (W5): when
``config.web.oidc is None`` that dependency is a no-op (open mode, W4
parity); when set, unauthenticated requests redirect to ``/login``.
``/healthz`` and ``/static`` stay unprotected either way.
``/healthz`` and ``/static`` stay unprotected either way. The mutating
routes (W6, ``web/routes.py``) are additionally behind CSRF -- see that
module's docstring for the full safety contract.
"""
# Deferred import: avoids a module-level import cycle (web.routes imports
# get_backend from this module).
from ci_orchestrator.web.routes import encode_vmx_id, register_action_routes
app = FastAPI(title="ci-orchestrator web")
app.state.config = config
app.state.templates = Jinja2Templates(directory=str(_WEB_DIR / "templates"))
app.state.templates.env.filters["vmx_id"] = encode_vmx_id
app.mount("/static", StaticFiles(directory=str(_WEB_DIR / "static")), name="static")
app.get("/healthz")(healthz)
@@ -187,11 +209,31 @@ def create_app(config: Config) -> FastAPI:
app.get("/host", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
host_partial
)
register_action_routes(app)
# SessionMiddleware (needed by the OAuth flow's state/nonce) is added
# here, before the app is ever handed to a server -- Starlette builds
# the middleware stack lazily on first request, so this is safe
# SessionMiddleware is ALWAYS installed (W6) -- CSRF needs
# ``request.session`` in both auth modes, not just when OIDC is active.
# Added here, before the app is ever handed to a server -- Starlette
# builds the middleware stack lazily on first request, so this is safe
# regardless of where the route registrations above sit.
#
# Active OIDC: persistent, keyring-sourced secret so a login survives a
# process restart. Inactive: a fresh per-process secret -- open mode
# still needs no keyring at all, and the session only needs to outlive
# this one process (there is nothing sensitive in it besides a CSRF
# token when auth is off).
session_secret = (
_read_secret(config.web.session_secret_cred)
if config.web.oidc is not None
else secrets.token_urlsafe(32)
)
app.add_middleware(
SessionMiddleware,
secret_key=session_secret,
same_site="lax",
https_only=False,
)
configure_auth(app, config)
return app