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
+15 -22
View File
@@ -3,11 +3,17 @@
**Auth is ACTIVE iff ``config.web.oidc is not None``.** When inactive (the
W4 default -- bare localhost dev), :func:`configure_auth` is a no-op and
:func:`require_user` degrades to a no-op that returns ``"anonymous"``; this
preserves the W4 open-localhost dashboard exactly. When active,
``SessionMiddleware`` (itsdangerous-signed cookies) is installed, an Authlib
preserves the W4 open-localhost dashboard exactly. When active, an Authlib
OAuth client is registered against Gitea's OIDC discovery document, and
``/login`` / ``/auth/callback`` / ``/logout`` are wired in.
**``SessionMiddleware`` is always installed (W6)** -- by
:func:`~ci_orchestrator.web.app.create_app`, not here. CSRF protection
(``web/security.py``) needs ``request.session`` in *both* auth modes, so the
middleware can no longer be conditional on ``config.web.oidc``. This module
only ever registers the OAuth client + login routes, gated on
``config.web.oidc is not None`` as before.
**Secrets come from the OS keyring only** -- the session signing key
(``config.web.session_secret_cred``) and the OIDC client secret
(``config.web.oidc.client_secret_cred``) are keyring key *names* in
@@ -29,7 +35,6 @@ from authlib.integrations.starlette_client import OAuth
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse
from starlette import status
from starlette.middleware.sessions import SessionMiddleware
from ci_orchestrator.credentials import KeyringCredentialStore
@@ -171,30 +176,18 @@ def configure_auth(app: FastAPI, config: Config) -> None:
Inactive (``config.web.oidc is None``): no-op, open mode (W4 parity) --
``require_user`` alone handles that case and the login routes are never
registered.
registered. ``SessionMiddleware`` is still present (added unconditionally
by ``create_app``), but nothing here needs it.
Active: adds ``SessionMiddleware`` *before* anything OAuth-related runs
(Authlib needs the session for state/nonce), registers the OAuth client
on ``app.state.oauth``, and registers ``/login``, ``/auth/callback``,
``/logout``.
Active: registers the OAuth client on ``app.state.oauth`` and registers
``/login``, ``/auth/callback``, ``/logout``. ``SessionMiddleware`` is
guaranteed to already be installed by the time this runs (Authlib needs
the session for state/nonce) -- ``create_app`` adds it before calling
this function.
"""
if config.web.oidc is None:
return
# https_only=False: this host serves plain HTTP on the LAN (no TLS
# termination in front of it, see the web-UI plan's gotchas) -- forcing
# https_only=True here would make the browser silently drop the session
# cookie and break every login. same_site="lax" still blocks the cookie
# being sent on cross-site requests; the cookie is httponly by
# SessionMiddleware's own default (unconditional, not toggled by
# https_only).
app.add_middleware(
SessionMiddleware,
secret_key=_read_secret(config.web.session_secret_cred),
same_site="lax",
https_only=False,
)
app.state.oauth = register_oauth(config)
app.add_api_route("/login", login, methods=["GET"])
+439
View File
@@ -0,0 +1,439 @@
"""Mutating web routes: VM remove/cleanup, cache purge, host backup, audit (W6).
**SECURITY-CRITICAL**: these routes destroy VMs, purge caches, and back up
templates. Every route in this module is registered behind
:func:`~ci_orchestrator.web.auth.require_user`. Every ``POST`` additionally
depends on :func:`~ci_orchestrator.web.security.require_csrf` and follows a
mandatory two-step confirm gate:
- A ``POST`` **without** ``stage=execute`` always runs the underlying
``operations/`` call with ``dry_run=True`` and renders a *confirm* partial
(``partials/confirm.html``) containing a fresh CSRF token and
``stage=execute`` -- there is no one-click destroy.
- A ``POST`` **with** ``stage=execute`` performs the real action.
Both stages append exactly one :class:`~ci_orchestrator.operations.audit.
AuditEntry`. Pure validation/security refusals (missing CSRF -> 403, VMX
outside ``config.paths.build_vms`` -> 400, a fresh ``vm-start.lock`` on
execute -> 409) short-circuit *before* any ``operations/`` call and are
never audited -- they are refusals to act, not actions.
In-process only: this module imports ``operations/`` directly and never
shells out to the CLI (see ``CLAUDE.md``).
"""
from __future__ import annotations
import base64
import binascii
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse
from starlette import status
from ci_orchestrator.operations.audit import AuditEntry, append_audit, read_audit
from ci_orchestrator.operations.cache import purge_cargo_cache
from ci_orchestrator.operations.host import backup_template
from ci_orchestrator.operations.vms import RemovalResult, cleanup_orphans, remove_vm
from ci_orchestrator.web.app import get_backend
from ci_orchestrator.web.auth import require_user
from ci_orchestrator.web.security import issue_csrf, require_csrf
if TYPE_CHECKING: # pragma: no cover
from fastapi.templating import Jinja2Templates
from starlette.datastructures import FormData
from ci_orchestrator.backends.protocol import VmBackend
from ci_orchestrator.config import Config
#: A ``vm-start.lock`` younger than this is treated as "a job may be
#: running" -- see the W6 plan's "best-effort job-race guard".
_LOCK_MAX_AGE_MINUTES = 30
_DEFAULT_CLEANUP_MAX_AGE_HOURS = 4
_STAGE_FIELD = "stage"
_STAGE_EXECUTE = "execute"
# ───────────────────────────────────────────────────────────── vmx_id codec
def encode_vmx_id(vmx: str | Path) -> str:
"""Urlsafe-base64 a VMX path for use as a ``/vms/{vmx_id}/remove`` path segment."""
return base64.urlsafe_b64encode(str(vmx).encode("utf-8")).decode("ascii").rstrip("=")
def _decode_vmx_id(vmx_id: str) -> Path | None:
"""Inverse of :func:`encode_vmx_id`. Returns ``None`` on any malformed input."""
padded = vmx_id + "=" * (-len(vmx_id) % 4)
try:
raw = base64.urlsafe_b64decode(padded)
except (binascii.Error, ValueError):
return None
try:
return Path(raw.decode("utf-8"))
except UnicodeDecodeError:
return None
def _is_contained(path: Path, base: Path) -> bool:
"""True iff ``base`` is an ancestor of (resolved) ``path`` -- never equal.
Both sides are ``.resolve()``d before comparison so a symlink or ``..``
segment cannot smuggle a path outside ``base`` past this check. This is
the sole gate standing between an authenticated web user and deleting an
arbitrary host path -- see the W6 plan's "path containment" invariant.
"""
try:
resolved_base = base.resolve()
resolved_path = path.resolve()
except OSError:
return False
return resolved_base in resolved_path.parents
# ───────────────────────────────────────────────────────────────── helpers
def _form_str(form: FormData, key: str, default: str = "") -> str:
value = form.get(key, default)
return value if isinstance(value, str) else default
def _form_int(form: FormData, key: str, default: int) -> int:
try:
return int(_form_str(form, key, str(default)))
except ValueError:
return default
def _lock_path(config: Config) -> Path:
"""The job-lifecycle lock path this route treats as "a job may be running".
Mirrors the ``vm-start.lock`` convention used by the job orchestrator
(``config.paths.build_vms.parent / "vm-start.lock"``). Advisory only:
as of W6 the Python job path does not yet create this lock file, so this
guard is forward-looking (see the W6 plan's gotcha note).
"""
return config.paths.build_vms.parent / "vm-start.lock"
def _lock_is_fresh(lock_file: Path) -> bool:
try:
mtime = datetime.fromtimestamp(lock_file.stat().st_mtime, tz=UTC)
except OSError:
return False
return mtime >= datetime.now(tz=UTC) - timedelta(minutes=_LOCK_MAX_AGE_MINUTES)
def _audit(
config: Config, *, user: str, action: str, target: str, result: str, detail: str
) -> None:
entry = AuditEntry(
ts=datetime.now(tz=UTC).isoformat(),
user=user,
action=action,
target=target,
result=result,
detail=detail,
)
append_audit(config.web.audit_log, entry)
def _confirm_partial(
request: Request,
*,
action_label: str,
target: str,
summary: str,
action_url: str,
extra_fields: dict[str, str],
) -> HTMLResponse:
templates: Jinja2Templates = request.app.state.templates
context = {
"action_label": action_label,
"target": target,
"summary": summary,
"action_url": action_url,
"extra_fields": extra_fields,
"csrf_token": issue_csrf(request),
}
return templates.TemplateResponse(request, "partials/confirm.html", context)
def _result_partial(request: Request, *, action_label: str, ok: bool, detail: str) -> HTMLResponse:
templates: Jinja2Templates = request.app.state.templates
context = {"action_label": action_label, "ok": ok, "detail": detail}
return templates.TemplateResponse(request, "partials/result.html", context)
def _lock_conflict_partial(request: Request, *, action_label: str, target: str) -> HTMLResponse:
templates: Jinja2Templates = request.app.state.templates
context = {"action_label": action_label, "target": target}
return templates.TemplateResponse(
request, "partials/lock_conflict.html", context, status_code=status.HTTP_409_CONFLICT
)
def _removal_detail(result: RemovalResult) -> str:
if result.error is not None:
return f"error removing {result.target}: {result.error}"
return f"removed {result.target} (dir removed: {result.dir_removed})"
# ─────────────────────────────────────────────────────────── POST /vms/{id}/remove
async def vms_remove(
vmx_id: str,
request: Request,
user: str = Depends(require_user),
_csrf: None = Depends(require_csrf),
backend: VmBackend | None = Depends(get_backend),
) -> HTMLResponse:
"""Stop + permanently delete a single VM, behind the two-step confirm gate."""
config: Config = request.app.state.config
vmx = _decode_vmx_id(vmx_id)
if vmx is None or not _is_contained(vmx, config.paths.build_vms):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="vmx path is not contained in config.paths.build_vms",
)
target = str(vmx)
action_label = "Remove VM"
if backend is None:
detail = "Backend unavailable (vmrun not found on this host)."
_audit(config, user=user, action="vm.remove", target=target, result="error", detail=detail)
return _result_partial(request, action_label=action_label, ok=False, detail=detail)
form = await request.form()
execute = _form_str(form, _STAGE_FIELD) == _STAGE_EXECUTE
if execute:
conflict = _lock_path(config)
if conflict.is_file() and _lock_is_fresh(conflict):
return _lock_conflict_partial(request, action_label=action_label, target=target)
result = remove_vm(backend, vmx, dry_run=False)
ok = result.error is None
detail = _removal_detail(result)
_audit(
config,
user=user,
action="vm.remove",
target=target,
result="ok" if ok else "error",
detail=detail,
)
return _result_partial(request, action_label=action_label, ok=ok, detail=detail)
result = remove_vm(backend, vmx, dry_run=True)
detail = f"would remove VM at {target}"
_audit(config, user=user, action="vm.remove", target=target, result="dry-run", detail=detail)
return _confirm_partial(
request,
action_label=action_label,
target=target,
summary=detail,
action_url=f"/vms/{vmx_id}/remove",
extra_fields={},
)
# ───────────────────────────────────────────────────────────── POST /vms/cleanup
async def vms_cleanup(
request: Request,
user: str = Depends(require_user),
_csrf: None = Depends(require_csrf),
backend: VmBackend | None = Depends(get_backend),
) -> HTMLResponse:
"""Remove orphaned clone directories older than a threshold, confirm-gated."""
config: Config = request.app.state.config
action_label = "Cleanup orphaned VMs"
target = str(config.paths.build_vms)
lock_file = _lock_path(config)
form = await request.form()
execute = _form_str(form, _STAGE_FIELD) == _STAGE_EXECUTE
max_age_hours = _form_int(form, "max_age_hours", _DEFAULT_CLEANUP_MAX_AGE_HOURS)
if execute:
if lock_file.is_file() and _lock_is_fresh(lock_file):
return _lock_conflict_partial(request, action_label=action_label, target=target)
results = cleanup_orphans(
config.paths.build_vms,
max_age_hours,
backend,
dry_run=False,
lock_file=lock_file,
)
ok = all(r.error is None for r in results)
detail = f"removed {len(results)} orphan(s) older than {max_age_hours}h"
_audit(
config,
user=user,
action="vm.cleanup",
target=target,
result="ok" if ok else "error",
detail=detail,
)
return _result_partial(request, action_label=action_label, ok=ok, detail=detail)
results = cleanup_orphans(
config.paths.build_vms,
max_age_hours,
backend,
dry_run=True,
lock_file=lock_file,
)
detail = f"would remove {len(results)} orphan(s) older than {max_age_hours}h"
_audit(config, user=user, action="vm.cleanup", target=target, result="dry-run", detail=detail)
return _confirm_partial(
request,
action_label=action_label,
target=target,
summary=detail,
action_url="/vms/cleanup",
extra_fields={"max_age_hours": str(max_age_hours)},
)
# ───────────────────────────────────────────────────────────── POST /cache/purge
async def cache_purge(
request: Request,
user: str = Depends(require_user),
_csrf: None = Depends(require_csrf),
) -> HTMLResponse:
"""Purge cargo cache entries past retention, confirm-gated."""
config: Config = request.app.state.config
action_label = "Purge cargo cache"
target = str(config.cargo_cache.root)
form = await request.form()
execute = _form_str(form, _STAGE_FIELD) == _STAGE_EXECUTE
try:
result = purge_cargo_cache(config, dry_run=not execute)
except Exception as exc: # never 500 on an operational failure
detail = f"error: {exc}"
_audit(config, user=user, action="cache.purge", target=target, result="error", detail=detail)
return _result_partial(request, action_label=action_label, ok=False, detail=detail)
if execute:
detail = f"freed {result.freed_bytes} bytes across {result.removed_entries} entrie(s)"
_audit(config, user=user, action="cache.purge", target=target, result="ok", detail=detail)
return _result_partial(request, action_label=action_label, ok=True, detail=detail)
detail = f"would remove {result.removed_entries} entrie(s), freeing {result.freed_bytes} bytes"
_audit(config, user=user, action="cache.purge", target=target, result="dry-run", detail=detail)
return _confirm_partial(
request,
action_label=action_label,
target=target,
summary=detail,
action_url="/cache/purge",
extra_fields={},
)
# ───────────────────────────────────────────────────────────── POST /host/backup
async def host_backup(
request: Request,
user: str = Depends(require_user),
_csrf: None = Depends(require_csrf),
) -> HTMLResponse:
"""Archive a template directory to a timestamped 7z file, confirm-gated."""
config: Config = request.app.state.config
action_label = "Backup template"
form = await request.form()
template = _form_str(form, "template")
if not template:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="template is required")
execute = _form_str(form, _STAGE_FIELD) == _STAGE_EXECUTE
try:
result = backup_template(config, template, dry_run=not execute)
except Exception as exc: # never 500 on an operational failure
detail = f"error: {exc}"
_audit(config, user=user, action="host.backup", target=template, result="error", detail=detail)
return _result_partial(request, action_label=action_label, ok=False, detail=detail)
if execute:
detail = (
f"archived to {result.archive_path}" if result.ok else (result.error or "unknown error")
)
_audit(
config,
user=user,
action="host.backup",
target=template,
result="ok" if result.ok else "error",
detail=detail,
)
return _result_partial(request, action_label=action_label, ok=result.ok, detail=detail)
if not result.ok:
# Nothing to confirm -- e.g. the template directory does not exist.
# This is a validation failure, not something a real run could fix.
detail = result.error or "unknown error"
_audit(config, user=user, action="host.backup", target=template, result="error", detail=detail)
return _result_partial(request, action_label=action_label, ok=False, detail=detail)
detail = f"would archive template '{template}' to {result.archive_path}"
_audit(config, user=user, action="host.backup", target=template, result="dry-run", detail=detail)
return _confirm_partial(
request,
action_label=action_label,
target=template,
summary=detail,
action_url="/host/backup",
extra_fields={"template": template},
)
# ──────────────────────────────────────────────────────────────── GET /audit
def audit_view(request: Request, user: str = Depends(require_user)) -> HTMLResponse:
"""Read-only audit log table. No CSRF -- this route mutates nothing."""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
entries = read_audit(config.web.audit_log, last=100)
return templates.TemplateResponse(request, "partials/audit.html", {"entries": entries})
# ─────────────────────────────────────────────────────────────────── wiring
def register_action_routes(app: FastAPI) -> None:
"""Register every W6 mutating route (plus the read-only ``/audit``) on ``app``."""
app.add_api_route(
"/vms/{vmx_id}/remove", vms_remove, methods=["POST"], response_class=HTMLResponse
)
app.add_api_route("/vms/cleanup", vms_cleanup, methods=["POST"], response_class=HTMLResponse)
app.add_api_route("/cache/purge", cache_purge, methods=["POST"], response_class=HTMLResponse)
app.add_api_route("/host/backup", host_backup, methods=["POST"], response_class=HTMLResponse)
app.add_api_route("/audit", audit_view, methods=["GET"], response_class=HTMLResponse)
__all__ = [
"audit_view",
"cache_purge",
"encode_vmx_id",
"host_backup",
"register_action_routes",
"vms_cleanup",
"vms_remove",
]
+69
View File
@@ -0,0 +1,69 @@
"""Synchronizer-token CSRF protection for mutating web routes (W6, SECURITY-CRITICAL).
Every mutating (``POST``) route in :mod:`ci_orchestrator.web.routes` depends
on :func:`require_csrf`. The token is a per-session ``secrets.token_urlsafe(32)``
value stored under the ``"csrf"`` key in the (always-on, see
``web/app.py::create_app``) signed session cookie -- never derived from a
secret, never logged, never put in a URL (form field only, so it never lands
in an access log or a browser history entry).
Requires ``SessionMiddleware`` to already be installed on the app (it is,
unconditionally, since W6 -- see ``web/app.py``); without it
``request.session`` raises ``AssertionError`` from Starlette itself.
"""
from __future__ import annotations
import hmac
import secrets
from fastapi import HTTPException, Request
from starlette import status
_CSRF_KEY = "csrf"
def issue_csrf(request: Request) -> str:
"""Return this session's CSRF token, minting one on first use.
Idempotent within a session: repeated calls (e.g. rendering several
action forms on one page) return the same token until the session is
cleared (``/logout``) or expires.
"""
token = request.session.get(_CSRF_KEY)
if not isinstance(token, str) or not token:
token = secrets.token_urlsafe(32)
request.session[_CSRF_KEY] = token
return token
def validate_csrf(request: Request, submitted: str | None) -> bool:
"""Constant-time comparison of ``submitted`` against the session's token.
Returns ``False`` (never raises) if the session has no token yet or
nothing was submitted -- :func:`require_csrf` turns that into a 403.
"""
expected = request.session.get(_CSRF_KEY)
if not isinstance(expected, str) or not expected or not submitted:
return False
return hmac.compare_digest(expected, submitted)
async def require_csrf(request: Request) -> None:
"""FastAPI dependency: 403s a mutating request with a missing/invalid CSRF token.
Reads the ``csrf`` form field -- the synchronizer-token pattern, a
hidden input re-embedded in every confirm form (see
``web/templates/partials/confirm.html``). Raises before any
``operations/`` call runs, so an invalid token never triggers so much as
a dry-run.
"""
form = await request.form()
submitted = form.get(_CSRF_KEY)
if not isinstance(submitted, str) or not validate_csrf(request, submitted):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="invalid or missing CSRF token"
)
__all__ = ["issue_csrf", "require_csrf", "validate_csrf"]
@@ -10,6 +10,7 @@
<body>
<nav class="navbar">
<span class="navbar-brand">ci-orchestrator</span>
<a href="/audit">Audit log</a>
</nav>
<main class="content">
{% block content %}{% endblock %}
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Audit log - ci-orchestrator{% endblock %}
{% block content %}
<section class="card" id="audit-card">
<h2>Audit log</h2>
{% if entries %}
<table>
<thead>
<tr>
<th>Time</th>
<th>User</th>
<th>Action</th>
<th>Target</th>
<th>Result</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{% for e in entries %}
<tr class="{{ 'row-alert' if e.result == 'error' else '' }}">
<td>{{ e.ts }}</td>
<td>{{ e.user }}</td>
<td>{{ e.action }}</td>
<td><code>{{ e.target }}</code></td>
<td>{{ e.result }}</td>
<td>{{ e.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No audit entries yet.</p>
{% endif %}
</section>
{% endblock %}
@@ -12,4 +12,8 @@
{% else %}
<p class="notice notice-warn">Cargo cache status unavailable.</p>
{% endif %}
<form method="post" action="/cache/purge" id="cache-purge-form">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger">Purge cache</button>
</form>
</div>
@@ -0,0 +1,13 @@
<div class="confirm-box notice notice-warn" id="action-confirm">
<h3>Confirm: {{ action_label }}</h3>
<p>Target: <code>{{ target }}</code></p>
<p>{{ summary }}</p>
<form method="post" action="{{ action_url }}">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input type="hidden" name="stage" value="execute">
{% for key, value in extra_fields.items() %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<button type="submit" class="btn btn-danger">Confirm</button>
</form>
</div>
@@ -37,3 +37,12 @@
<p class="notice notice-warn">Runner status unavailable.</p>
{% endif %}
</section>
<section class="card" id="backup-card">
<h2>Template backup</h2>
<form method="post" action="/host/backup" id="host-backup-form">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<label>Template: <input type="text" name="template" required></label>
<button type="submit" class="btn btn-danger">Backup template</button>
</form>
</section>
@@ -0,0 +1,5 @@
<div class="notice notice-warn" id="action-lock-conflict">
<h3>{{ action_label }} refused</h3>
<p>A job may be running against <code>{{ target }}</code> (a fresh
<code>vm-start.lock</code> is present). Try again once it clears.</p>
</div>
@@ -0,0 +1,4 @@
<div class="action-result {{ 'result-ok' if ok else 'result-error' }}" id="action-result">
<h3>{{ action_label }} -- {{ "ok" if ok else "error" }}</h3>
<p>{{ detail }}</p>
</div>
@@ -13,6 +13,7 @@
<th>Guest OS</th>
<th>Uptime (s)</th>
<th>Orphan?</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@@ -24,6 +25,12 @@
<td>{{ vm.guest_os or "-" }}</td>
<td>{{ vm.uptime_sec if vm.uptime_sec is not none else "-" }}</td>
<td>{{ "yes" if vm.is_orphan else "no" }}</td>
<td>
<form method="post" action="/vms/{{ vm.vmx | vmx_id }}/remove">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger">Remove</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
@@ -31,4 +38,9 @@
{% else %}
<p>No VMs currently running.</p>
{% endif %}
<form method="post" action="/vms/cleanup" id="vms-cleanup-form">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<label>Max age (h): <input type="number" name="max_age_hours" value="4" min="0"></label>
<button type="submit" class="btn btn-danger">Cleanup orphans</button>
</form>
</div>