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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user