feat(web): Gitea OIDC auth, signed session, allowlist (W5)

This commit is contained in:
2026-07-23 17:51:36 +02:00
parent 10b5bef1fc
commit bc421e5009
4 changed files with 710 additions and 11 deletions
+32 -11
View File
@@ -1,12 +1,15 @@
"""FastAPI application factory for the read-only web dashboard (W4).
"""FastAPI application factory for the web dashboard (W4 read-only + W5 auth).
**In-process only**: routes call ``operations/`` + ``backends.load_backend``
directly and render Jinja2 templates. They never shell out to the CLI (see
``CLAUDE.md`` / the web-UI plan's architecture rules).
**No auth in this step** (that is W5) -- the app binds ``127.0.0.1`` by
default via :class:`~ci_orchestrator.config.WebConfig`; ``create_app`` itself
never chooses the bind address, that is ``commands/web.py``'s job.
**Auth (W5)**: the four data routes below are protected by
:func:`~ci_orchestrator.web.auth.require_user`. Auth is ACTIVE iff
``config.web.oidc is not None``; when inactive (the W4 default -- bare
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.
**Backend may be unavailable**: :func:`get_backend` is a FastAPI dependency
that swallows :class:`~ci_orchestrator.backends.errors.BackendError` (raised
@@ -29,6 +32,7 @@ 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
if TYPE_CHECKING: # pragma: no cover
from ci_orchestrator.backends.protocol import VmBackend
@@ -156,11 +160,16 @@ def host_partial(request: Request) -> HTMLResponse:
def create_app(config: Config) -> FastAPI:
"""Build the read-only dashboard app.
"""Build the dashboard app.
W4 scope: no auth, read-only routes only. Binding to a concrete address
(always ``127.0.0.1`` by default -- never ``0.0.0.0``) is
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.
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.
"""
app = FastAPI(title="ci-orchestrator web")
app.state.config = config
@@ -168,10 +177,22 @@ def create_app(config: Config) -> FastAPI:
app.mount("/static", StaticFiles(directory=str(_WEB_DIR / "static")), name="static")
app.get("/healthz")(healthz)
app.get("/", response_class=HTMLResponse)(dashboard)
app.get("/vms", response_class=HTMLResponse)(vms_partial)
app.get("/cache", response_class=HTMLResponse)(cache_partial)
app.get("/host", response_class=HTMLResponse)(host_partial)
app.get("/", response_class=HTMLResponse, dependencies=[Depends(require_user)])(dashboard)
app.get("/vms", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
vms_partial
)
app.get("/cache", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
cache_partial
)
app.get("/host", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
host_partial
)
# 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
# regardless of where the route registrations above sit.
configure_auth(app, config)
return app