- Add migration 0010: utterance_translations table with UNIQUE(utterance_id, language_code)
- Add schemas: OrganizationCreateRequest/Response, ProgramCreateRequest/Response,
UtteranceTranslationItem/Request/Response/ListResponse, SpeakerSyncRequest/Response
- Add service methods: create_organization, create_program, sync_speakers,
upsert_utterance_translations, get_utterance_translations
- Add routes_controller.py: POST /api/orgs, POST /api/programs,
POST /api/speakers/sync, POST /api/projects/{id}/translations,
GET /api/projects/{id}/translations/{language_code}
- Mount controller_router at /api in app.py
- Create bdumbra account (member, BDMurphy org) for integration testing
705 lines
29 KiB
Python
705 lines
29 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""FastAPI application factory, security middleware, and shared dependencies.
|
|
|
|
Design decisions worth stating explicitly:
|
|
|
|
* Handlers are synchronous ``def`` functions. FastAPI runs them in its worker
|
|
threadpool, so the CPU-bound Argon2 work does not block the event loop.
|
|
* The session token travels in an ``HttpOnly``, ``SameSite=Lax`` cookie that is
|
|
``Secure`` whenever production settings are used. The raw token is never
|
|
written to a log, a response body, or the repository.
|
|
* CSRF protection uses the double-submit pattern: a readable cookie plus an
|
|
``X-CSRF-Token`` header compared in constant time. ``POST /api/auth/login`` is
|
|
exempt because it carries credentials the attacker does not have; every other
|
|
state-changing API route requires the header.
|
|
* Error responses are a fixed ``{"error": {"code", "message"}}`` shape. FastAPI's
|
|
default validation payload echoes the submitted value, so a custom handler
|
|
strips it: a rejected password must not be reflected to the client.
|
|
* No CORS middleware is installed. The API is same-origin only; the approved
|
|
reverse proxy terminates TLS in production.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from contextlib import asynccontextmanager, suppress
|
|
from datetime import datetime, timezone
|
|
from http.cookies import SimpleCookie
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
from uuid import uuid4
|
|
|
|
from fastapi import Depends, FastAPI, Request, Response
|
|
from fastapi.exceptions import RequestValidationError
|
|
|
|
# Import BundleError for exception handling; it's defined in audio_import module.
|
|
BundleError: Any = None # type: ignore[assignment]
|
|
try:
|
|
from .audio_import import BundleError # noqa: F401
|
|
except ImportError:
|
|
# BundleError may not exist in older versions or when imported early;
|
|
# suppress error so the app still starts. The exception handler will be
|
|
# skipped if BundleError is None.
|
|
pass
|
|
|
|
from typing import Any
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from starlette.datastructures import MutableHeaders
|
|
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
|
|
from .config import PRODUCTION_REPOSITORY_KIND, Settings, load_settings
|
|
from .errors import (
|
|
AuthenticationRequiredError,
|
|
ConfigurationError,
|
|
CsrfError,
|
|
ForbiddenError,
|
|
InvalidCredentialsError,
|
|
PasswordChangeRequiredError,
|
|
PasswordPolicyError,
|
|
UmbraWebError,
|
|
)
|
|
from .models import Principal, RoleName
|
|
from .postgres_repository import PostgresAccountRepository
|
|
from .repository import AccountRepository, build_repository
|
|
from .schemas import HealthResponse
|
|
from .security import generate_token, install_secret_redaction_filter, tokens_match
|
|
from .service import AccountService
|
|
from .setup import SetupGate
|
|
|
|
LOGGER = logging.getLogger("umbratrans_web.app")
|
|
|
|
#: Unsafe methods require a CSRF token.
|
|
UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
|
|
#: The login route is exempt: it carries credentials the attacker cannot know,
|
|
#: and requiring a prior CSRF bootstrap would break the sign-in page.
|
|
CSRF_EXEMPT_PATHS = frozenset(
|
|
{
|
|
"/api/auth/login",
|
|
# No session exists yet, so there is no ambient authority for a
|
|
# cross-site request to ride on. The setup token is the authenticator.
|
|
"/api/setup/initial-administrator",
|
|
}
|
|
)
|
|
|
|
#: Prefixes exempt from the CSRF check, for a specific and narrow reason.
|
|
#:
|
|
#: CSRF defends against a *browser* being induced to send a request carrying the
|
|
#: user's cookies. It is meaningless for a caller that authenticates with a
|
|
#: credential in the ``Authorization`` header: there is no ambient authority to
|
|
#: ride on, a cross-site page cannot read the key, and the key is not attached
|
|
#: automatically the way a cookie is.
|
|
#:
|
|
#: `/api/node` is exempt on exactly that basis. It is a prefix rather than an
|
|
#: enumerated set of paths, because the node surface is small, is entirely
|
|
#: bearer-authenticated, and grows; enumerating it would mean a new node route
|
|
#: is broken until somebody remembers to add it here. Everything else stays
|
|
#: checked, and a test in ``test_compute.py`` asserts that no administrator or
|
|
#: session route has become exempt.
|
|
CSRF_EXEMPT_PREFIXES = ("/api/node",)
|
|
|
|
SESSION_REVOKED_CODES = frozenset({"authentication_required", "invalid_credentials"})
|
|
|
|
|
|
def request_id(request: Request) -> str:
|
|
"""Return the correlation id assigned to the current request."""
|
|
|
|
return cast(str, getattr(request.state, "request_id", "") or "")
|
|
|
|
|
|
def get_settings(request: Request) -> Settings:
|
|
return cast(Settings, request.app.state.settings)
|
|
|
|
|
|
def get_service(request: Request) -> AccountService:
|
|
return cast(AccountService, request.app.state.service)
|
|
|
|
|
|
def get_repository(request: Request) -> AccountRepository:
|
|
return cast(AccountRepository, request.app.state.repository)
|
|
|
|
|
|
def _cookie_value(header: str | None, name: str) -> str | None:
|
|
if not header:
|
|
return None
|
|
jar = SimpleCookie()
|
|
try:
|
|
jar.load(header)
|
|
except Exception: # noqa: BLE001 - malformed cookie header must not raise
|
|
return None
|
|
morsel = jar.get(name)
|
|
return morsel.value if morsel else None
|
|
|
|
|
|
def session_token(request: Request) -> str | None:
|
|
"""Extract the session token from the request cookie."""
|
|
|
|
settings = get_settings(request)
|
|
return _cookie_value(request.headers.get("cookie"), settings.session_cookie_name)
|
|
|
|
|
|
def read_csrf_token(request: Request) -> str:
|
|
settings = get_settings(request)
|
|
return _cookie_value(request.headers.get("cookie"), settings.csrf_cookie_name) or ""
|
|
|
|
|
|
def build_cookie(
|
|
name: str,
|
|
value: str,
|
|
*,
|
|
max_age: int | None = None,
|
|
http_only: bool = False,
|
|
secure: bool = False,
|
|
same_site: str = "lax",
|
|
path: str = "/",
|
|
) -> str:
|
|
"""Serialize one ``Set-Cookie`` header value."""
|
|
|
|
jar: SimpleCookie[str] = SimpleCookie()
|
|
jar[name] = value
|
|
morsel = jar[name]
|
|
morsel["path"] = path
|
|
if max_age is not None:
|
|
morsel["max-age"] = str(max_age)
|
|
morsel["expires"] = "Thu, 01 Jan 1970 00:00:00 GMT"
|
|
if http_only:
|
|
morsel["httponly"] = True
|
|
if secure:
|
|
morsel["secure"] = True
|
|
morsel["samesite"] = same_site.capitalize()
|
|
return jar.output(header="").strip()
|
|
|
|
|
|
def set_session_cookie(response: Response, *, token: str, settings: Settings) -> None:
|
|
response.set_cookie(
|
|
key=settings.session_cookie_name,
|
|
value=token,
|
|
max_age=settings.session_absolute_seconds,
|
|
httponly=True,
|
|
secure=settings.cookie_secure,
|
|
samesite=settings.cookie_samesite,
|
|
path=settings.cookie_path,
|
|
)
|
|
|
|
|
|
def clear_session_cookie(response: Response, *, settings: Settings) -> None:
|
|
response.delete_cookie(
|
|
key=settings.session_cookie_name,
|
|
path=settings.cookie_path,
|
|
secure=settings.cookie_secure,
|
|
httponly=True,
|
|
samesite=settings.cookie_samesite,
|
|
)
|
|
|
|
|
|
def set_csrf_cookie(response: Response, *, token: str, settings: Settings) -> None:
|
|
"""Issue the readable double-submit cookie. Not ``HttpOnly`` by design."""
|
|
|
|
response.set_cookie(
|
|
key=settings.csrf_cookie_name,
|
|
value=token,
|
|
max_age=settings.session_absolute_seconds,
|
|
httponly=False,
|
|
secure=settings.cookie_secure,
|
|
samesite=settings.cookie_samesite,
|
|
path=settings.cookie_path,
|
|
)
|
|
|
|
|
|
def clear_csrf_cookie(response: Response, *, settings: Settings) -> None:
|
|
response.delete_cookie(
|
|
key=settings.csrf_cookie_name,
|
|
path=settings.cookie_path,
|
|
secure=settings.cookie_secure,
|
|
httponly=False,
|
|
samesite=settings.cookie_samesite,
|
|
)
|
|
|
|
|
|
def ensure_csrf_token(response: Response, *, settings: Settings, existing: str | None = None) -> str:
|
|
"""Return the current CSRF token, issuing one if the request had none."""
|
|
|
|
token = existing or generate_token()
|
|
if not existing:
|
|
set_csrf_cookie(response, token=token, settings=settings)
|
|
return token
|
|
|
|
|
|
class ApiSecurityMiddleware:
|
|
"""Request ids, response hardening headers, and CSRF enforcement.
|
|
|
|
Implemented as raw ASGI so it also covers requests rejected before routing
|
|
and so response headers can be added without buffering the body.
|
|
"""
|
|
|
|
def __init__(self, app: ASGIApp, settings: Settings) -> None:
|
|
self._app = app
|
|
self._settings = settings
|
|
|
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
if scope["type"] != "http":
|
|
await self._app(scope, receive, send)
|
|
return
|
|
|
|
headers = MutableHeaders(scope=scope)
|
|
correlation = headers.get("x-request-id") or uuid4().hex
|
|
if len(correlation) > 128 or not correlation.isprintable():
|
|
correlation = uuid4().hex
|
|
state = scope.setdefault("state", {})
|
|
state["request_id"] = correlation
|
|
|
|
path = scope.get("path", "")
|
|
is_api = path.startswith("/api")
|
|
is_html = not is_api and scope.get("method", "GET").upper() == "GET"
|
|
method = scope.get("method", "GET").upper()
|
|
|
|
csrf_exempt = path in CSRF_EXEMPT_PATHS or path.startswith(CSRF_EXEMPT_PREFIXES)
|
|
if is_api and self._settings.require_csrf and method in UNSAFE_METHODS and not csrf_exempt:
|
|
problem = self._check_csrf(headers)
|
|
if problem is not None:
|
|
response = JSONResponse(
|
|
status_code=problem.http_status,
|
|
content={"error": {"code": problem.code, "message": problem.message}},
|
|
)
|
|
await response(scope, receive, send)
|
|
return
|
|
|
|
csrf_cookie = _cookie_value(headers.get("cookie"), self._settings.csrf_cookie_name)
|
|
should_issue_csrf = is_api and method not in UNSAFE_METHODS and not csrf_cookie
|
|
issued_csrf = generate_token() if should_issue_csrf else None
|
|
|
|
async def send_wrapper(message: Message) -> None:
|
|
if message["type"] == "http.response.start":
|
|
response_headers = MutableHeaders(scope=message)
|
|
response_headers["x-request-id"] = correlation
|
|
response_headers["x-content-type-options"] = "nosniff"
|
|
response_headers["x-frame-options"] = "DENY"
|
|
response_headers["referrer-policy"] = "no-referrer"
|
|
response_headers["cross-origin-opener-policy"] = "same-origin"
|
|
if is_api:
|
|
response_headers["cache-control"] = "no-store"
|
|
response_headers["content-security-policy"] = "default-src 'none'; frame-ancestors 'none'"
|
|
response_headers["vary"] = "Cookie"
|
|
elif is_html:
|
|
# The browser application. Same restrictive framing and
|
|
# sniffing policy as the API, plus a policy that only allows
|
|
# its own origin: no inline script, no remote origins.
|
|
response_headers["cache-control"] = "no-store"
|
|
response_headers["content-security-policy"] = (
|
|
"default-src 'self'; base-uri 'none'; form-action 'self'; "
|
|
"frame-ancestors 'none'; object-src 'none'; img-src 'self' data:; "
|
|
"style-src 'self'; script-src 'self'"
|
|
)
|
|
response_headers["vary"] = "Cookie"
|
|
if issued_csrf is not None:
|
|
response_headers.append(
|
|
"set-cookie",
|
|
build_cookie(
|
|
self._settings.csrf_cookie_name,
|
|
issued_csrf,
|
|
max_age=self._settings.session_absolute_seconds,
|
|
http_only=False,
|
|
secure=self._settings.cookie_secure,
|
|
same_site=self._settings.cookie_samesite,
|
|
path=self._settings.cookie_path,
|
|
),
|
|
)
|
|
await send(message)
|
|
|
|
await self._app(scope, receive, send_wrapper)
|
|
|
|
def _check_csrf(self, headers: MutableHeaders) -> CsrfError | None:
|
|
cookie = _cookie_value(headers.get("cookie"), self._settings.csrf_cookie_name)
|
|
header = headers.get(self._settings.csrf_header_name)
|
|
if not cookie or not header:
|
|
return CsrfError("csrf_token_missing", "This request is missing its CSRF token. Reload and retry.")
|
|
if not tokens_match(header, cookie):
|
|
return CsrfError("csrf_token_invalid")
|
|
return None
|
|
|
|
|
|
def get_principal_allowing_password_change(request: Request) -> Principal:
|
|
"""Resolve the session, tolerating a mandatory first-password change."""
|
|
|
|
token = session_token(request)
|
|
if not token:
|
|
raise AuthenticationRequiredError()
|
|
service = get_service(request)
|
|
try:
|
|
return service.resolve_session(token, request_id=request_id(request))
|
|
except InvalidCredentialsError as error:
|
|
raise AuthenticationRequiredError() from error
|
|
|
|
|
|
def require_principal(principal: Principal = Depends(get_principal_allowing_password_change)) -> Principal:
|
|
"""Require a fully usable session."""
|
|
|
|
if principal.must_change_password:
|
|
raise PasswordChangeRequiredError()
|
|
return principal
|
|
|
|
|
|
def require_admin(principal: Principal = Depends(require_principal)) -> Principal:
|
|
"""Require a platform or organization administrator session."""
|
|
|
|
if not principal.is_admin:
|
|
raise ForbiddenError()
|
|
return principal
|
|
|
|
|
|
def error_payload(error: UmbraWebError) -> tuple[int, dict[str, dict[str, Any]]]:
|
|
"""Map a domain error to a status and a fixed-shape body.
|
|
|
|
Messages are only echoed for client errors (4xx). A 5xx always returns a
|
|
generic body so an internal message can never leak configuration. A
|
|
password-policy failure additionally names the unmet *rules*; it never
|
|
echoes the candidate value.
|
|
"""
|
|
|
|
status = error.http_status
|
|
message = error.message if 400 <= status < 500 else "The request could not be completed."
|
|
body: dict[str, Any] = {"code": error.code, "message": message}
|
|
if isinstance(error, PasswordPolicyError) and error.unmet_requirements:
|
|
body["requirements"] = list(error.unmet_requirements)
|
|
return status, {"error": body}
|
|
|
|
|
|
def validation_fields(error: RequestValidationError) -> list[str]:
|
|
"""Return the field names of a validation failure, never the values.
|
|
|
|
FastAPI's default payload embeds the submitted input, which for this API
|
|
could be a password or a session token. Only the dotted field path is kept.
|
|
"""
|
|
|
|
skip = {"body", "query", "path", "header", "cookie"}
|
|
names: set[str] = set()
|
|
for item in error.errors():
|
|
parts = [str(part) for part in item.get("loc", ()) if str(part) not in skip]
|
|
names.add(".".join(parts) if parts else "payload")
|
|
return sorted(names)
|
|
|
|
|
|
def _begin_setup_if_needed(gate: SetupGate, service: AccountService) -> None:
|
|
"""Mint the first-run setup token if the deployment has no administrator.
|
|
|
|
Called once at startup. A failure to reach the store here is logged and
|
|
swallowed rather than allowed to abort startup: the same question is asked
|
|
again on every setup request, so a store that was briefly unavailable at boot
|
|
costs a restart later rather than a process that will not start.
|
|
"""
|
|
|
|
try:
|
|
gate.begin_if_needed(platform_administrators=service.repository.count_users_with_role(RoleName.PLATFORM_ADMIN))
|
|
except Exception: # noqa: BLE001 - setup is retried per request
|
|
LOGGER.warning("could not determine whether first-run setup is required", exc_info=True)
|
|
|
|
|
|
#: How often expired leases are swept, in seconds. Shorter than the shortest
|
|
#: lease any node may be configured with, so a job is never recovered later than
|
|
#: one lease period after its holder stopped renewing.
|
|
LEASE_SWEEP_SECONDS = 10.0
|
|
|
|
|
|
async def _sweep_expired_leases(app: FastAPI) -> None:
|
|
"""Return jobs whose holder stopped renewing, without being asked.
|
|
|
|
The lease is the whole recovery mechanism: a worker that is ``kill -9``'d
|
|
never releases anything, so the expiry *is* the signal. And a signal nobody
|
|
listens to is not a signal.
|
|
|
|
This used to exist only as an administrator endpoint, on the understanding
|
|
that a controller timer would call it. There was no such timer, so the
|
|
recovery path depended on a human noticing a stalled job and posting to an
|
|
administrative route by hand. That is not a mechanism; it is a diagnosis. The
|
|
proof of concept masked it, because the demonstration swept by hand itself.
|
|
|
|
Errors are logged and the loop continues. A sweeper that dies on one bad lease
|
|
is a queue that silently stops recovering, which is the exact failure it
|
|
exists to prevent, and the failure would be invisible.
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
log = logging.getLogger("umbratrans_web.compute")
|
|
while True:
|
|
try:
|
|
await asyncio.sleep(LEASE_SWEEP_SECONDS)
|
|
repository = getattr(app.state, "compute_repository", None)
|
|
recovered = repository.sweep_expired_leases() if repository is not None else []
|
|
if recovered:
|
|
log.warning(
|
|
"returned %d job(s) whose lease expired: %s",
|
|
len(recovered),
|
|
", ".join(str(job_id) for job_id in recovered),
|
|
)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
log.exception("lease sweep failed; will try again in %.0fs", LEASE_SWEEP_SECONDS)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _lifespan(app: FastAPI):
|
|
"""Run the lease sweeper for as long as the application serves.
|
|
|
|
The compute repository is built here rather than on first use, because the
|
|
sweeper needs one before any request arrives. A controller restarted while a
|
|
job was running must recover that job: if the repository were built lazily,
|
|
nothing would touch the compute surface, nothing would build it, and the
|
|
sweeper would have nothing to sweep.
|
|
|
|
A deployment with no database configured gets no repository and no sweeper,
|
|
which is not a degraded compute tier but the absence of one.
|
|
"""
|
|
|
|
if getattr(app.state.settings, "database_dsn", None):
|
|
from .compute import build_compute_repository
|
|
|
|
try:
|
|
app.state.compute_repository = build_compute_repository(
|
|
app.state.settings, getattr(app.state, "password_service", None)
|
|
)
|
|
except Exception:
|
|
# Refusing to serve would turn a compute problem into an outage of
|
|
# the account and project surfaces, which do not need it. The sweeper
|
|
# skips its round and the compute routes report the real error.
|
|
logging.getLogger("umbratrans_web.compute").exception(
|
|
"compute repository unavailable at startup; lease recovery is off until "
|
|
"a compute request builds one"
|
|
)
|
|
|
|
task = asyncio.create_task(_sweep_expired_leases(app))
|
|
app.state.lease_sweeper = task
|
|
try:
|
|
yield
|
|
finally:
|
|
task.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await task
|
|
|
|
|
|
def create_app(
|
|
*,
|
|
settings: Settings | None = None,
|
|
repository: AccountRepository | None = None,
|
|
service: AccountService | None = None,
|
|
) -> FastAPI:
|
|
"""Build the phase-1 application.
|
|
|
|
The repository is built from settings when one is not injected, which is how
|
|
a deployment runs: the configuration gate has already refused a production
|
|
environment without a PostgreSQL DSN, and the repository boundary refuses
|
|
anything but the PostgreSQL adapter. The check below is a second, independent
|
|
statement of the same rule at the point where it would otherwise be
|
|
unstated.
|
|
"""
|
|
|
|
resolved_settings = settings or load_settings()
|
|
|
|
# Checked against the configured kind *before* anything is opened, so refusing
|
|
# never involves a connection attempt. The earlier version of this check
|
|
# demanded the repository be injected and refused to start otherwise, on the
|
|
# grounds that the PostgreSQL adapter did not exist yet. It does now, so what
|
|
# is being asserted is which adapter is in use, not where it came from.
|
|
if resolved_settings.is_production and resolved_settings.repository_kind != PRODUCTION_REPOSITORY_KIND:
|
|
raise ConfigurationError(
|
|
"production_repository_missing",
|
|
"Production requires the PostgreSQL repository adapter.",
|
|
)
|
|
|
|
resolved_repository = build_repository(resolved_settings, repository=repository)
|
|
|
|
# Repeated after the build, because an injected repository bypasses the
|
|
# configured kind. This is the last point before the process serves anything.
|
|
if resolved_settings.is_production and not isinstance(resolved_repository, PostgresAccountRepository):
|
|
raise ConfigurationError(
|
|
"production_repository_missing",
|
|
"Production requires the PostgreSQL repository adapter.",
|
|
)
|
|
|
|
resolved_service = service or AccountService(resolved_repository, resolved_settings)
|
|
# The same transport the service will use, so the health check probes the
|
|
# configuration that is actually in force rather than a second, separately
|
|
# constructed one that could disagree.
|
|
resolved_service.mailer # fail fast if the transport cannot be built
|
|
|
|
logging.getLogger("umbratrans_web").setLevel(logging.INFO)
|
|
install_secret_redaction_filter()
|
|
|
|
app = FastAPI(
|
|
title="UmbraTrans account management",
|
|
version=resolved_settings.version,
|
|
docs_url=None,
|
|
redoc_url=None,
|
|
openapi_url=None,
|
|
lifespan=_lifespan,
|
|
)
|
|
app.state.settings = resolved_settings
|
|
app.state.repository = resolved_repository
|
|
app.state.service = resolved_service
|
|
|
|
# First-run setup. The token is minted here, at boot, and only when no
|
|
# platform administrator exists; the gate holds it and the route layer never
|
|
# reads it back out. A configured deployment mints nothing and holds nothing.
|
|
setup_gate = SetupGate()
|
|
app.state.setup_gate = setup_gate
|
|
# The compute repository hashes node credentials with the *same* argon2id
|
|
# profile as user passwords, so the profile is published rather than letting a
|
|
# second code path default its own. Two profiles for two credential kinds is
|
|
# two things to review, and one of them will be wrong.
|
|
app.state.password_service = resolved_service.password_service
|
|
_begin_setup_if_needed(setup_gate, resolved_service)
|
|
|
|
if resolved_settings.allowed_hosts:
|
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(resolved_settings.allowed_hosts))
|
|
app.add_middleware(ApiSecurityMiddleware, settings=resolved_settings)
|
|
|
|
@app.exception_handler(UmbraWebError)
|
|
async def _handle_domain_error(_request: Request, error: UmbraWebError) -> JSONResponse:
|
|
status, body = error_payload(error)
|
|
headers: dict[str, str] = {}
|
|
if isinstance(error, AuthenticationRequiredError) or error.code in SESSION_REVOKED_CODES:
|
|
headers["x-umbratrans-reauthenticate"] = "1"
|
|
return JSONResponse(status_code=status, content=body, headers=headers)
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def _handle_validation_error(_request: Request, error: RequestValidationError) -> JSONResponse:
|
|
# FastAPI's default payload echoes the submitted input. Strip it: a
|
|
# rejected password must never be reflected back to the client.
|
|
return JSONResponse(
|
|
status_code=422,
|
|
content={
|
|
"error": {
|
|
"code": "invalid_request",
|
|
"message": "The request payload is invalid.",
|
|
"fields": validation_fields(error),
|
|
}
|
|
},
|
|
)
|
|
|
|
# Conditionally register BundleError handler only if BundleError exists.
|
|
if BundleError is not None: # type: ignore[comparison-with-none]
|
|
@app.exception_handler(BundleError)
|
|
async def _handle_bundle_error(_request: Request, error: BundleError) -> JSONResponse:
|
|
# Malformed bundles get 422 with structured envelope instead of raw 500.
|
|
return JSONResponse(
|
|
status_code=422,
|
|
content={
|
|
"error": {
|
|
"code": "invalid_bundle",
|
|
"message": str(error),
|
|
}
|
|
},
|
|
)
|
|
|
|
from .routes_auth import router as auth_router
|
|
from .routes_compute import admin_router as compute_router
|
|
from .routes_compute import agent_router as node_router
|
|
from .routes_controller import router as controller_router
|
|
from .routes_setup import router as setup_router
|
|
from .routes_uadmin import router as uadmin_router
|
|
from .routes_workspace import router as workspace_router
|
|
|
|
app.include_router(setup_router, prefix="/api/setup", tags=["setup"])
|
|
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
|
app.include_router(uadmin_router, prefix="/api/uadmin", tags=["uadmin"])
|
|
# Controller-integration endpoints for the UmbraAudio node. Mounted at /api
|
|
# (no workspace prefix) because they are machine-to-machine routes, not
|
|
# member dashboard routes.
|
|
app.include_router(controller_router, prefix="/api", tags=["controller"])
|
|
# Mounted at /api rather than /api/auth: this is the member's own view, not
|
|
# something they authenticate *to*, and it needs no administrative role.
|
|
app.include_router(workspace_router, prefix="/api/workspace", tags=["workspace"])
|
|
# Two mounts of one router, because the two surfaces have genuinely different
|
|
# authentication. /api/compute/* is the administrator's inventory and queue
|
|
# view and needs a session with an administrative role, like every other
|
|
# uadmin route. /api/node/* is what a node agent may do, and it authenticates
|
|
# with a node credential that can claim work and report a result and nothing
|
|
# else. Mounting them together would mean one of the two checks is decorative.
|
|
app.include_router(compute_router, prefix="/api/compute", tags=["compute"])
|
|
app.include_router(node_router, prefix="/api/node", tags=["node"])
|
|
|
|
@app.get("/api/health", tags=["health"])
|
|
def health() -> HealthResponse:
|
|
"""Unauthenticated liveness probe. Contains no secret or database detail."""
|
|
|
|
fingerprint = resolved_settings.public_fingerprint()
|
|
return HealthResponse(
|
|
status="ok",
|
|
service=resolved_settings.service_name,
|
|
version=resolved_settings.version,
|
|
environment=str(fingerprint["environment"]),
|
|
repository=str(fingerprint["repository"]),
|
|
cookie_secure=bool(fingerprint["cookie_secure"]),
|
|
csrf_required=bool(fingerprint["csrf_required"]),
|
|
# Configured *and* verified. A host, a port, and a password can all be
|
|
# present and still be refused by the server, and reporting that as
|
|
# working would let the control panel create accounts whose
|
|
# confirmation links are never delivered.
|
|
mail_deliverable=resolved_service.mail_is_deliverable(),
|
|
mail_delivery_waived=bool(fingerprint["mail_delivery_waived"]),
|
|
setup_required=resolved_service.setup_is_required(),
|
|
time=datetime.now(timezone.utc),
|
|
)
|
|
|
|
LOGGER.info(
|
|
"application created environment=%s repository=%s csrf_required=%s cookie_secure=%s",
|
|
resolved_settings.environment,
|
|
resolved_settings.repository_kind,
|
|
resolved_settings.require_csrf,
|
|
resolved_settings.cookie_secure,
|
|
)
|
|
if resolved_settings.static_root is not None:
|
|
_mount_frontend(app, resolved_settings.static_root)
|
|
LOGGER.info("frontend mounted from %s", resolved_settings.static_root)
|
|
return app
|
|
|
|
|
|
def _mount_frontend(app: FastAPI, static_root: Path) -> None:
|
|
"""Serve the browser application from the same origin as the API.
|
|
|
|
Same-origin is not a convenience here. The session cookie is ``HttpOnly``
|
|
and the CSRF token travels as a header, and the application deliberately
|
|
installs no CORS middleware, so a separately hosted frontend could not call
|
|
the API at all. Mounting after the routers are registered keeps every
|
|
``/api`` route ahead of the static files.
|
|
|
|
A missing or non-directory root is refused rather than silently serving an
|
|
API-only application, because that would look like a broken deployment.
|
|
"""
|
|
|
|
root = static_root.expanduser()
|
|
if not root.is_dir():
|
|
raise ConfigurationError("static_root_missing", f"Static root is not a directory: {root}")
|
|
app.mount("/", StaticFiles(directory=str(root), html=True), name="frontend")
|
|
|
|
|
|
__all__ = [
|
|
"ApiSecurityMiddleware",
|
|
"CSRF_EXEMPT_PATHS",
|
|
"UNSAFE_METHODS",
|
|
"build_cookie",
|
|
"clear_csrf_cookie",
|
|
"clear_session_cookie",
|
|
"create_app",
|
|
"ensure_csrf_token",
|
|
"error_payload",
|
|
"get_principal_allowing_password_change",
|
|
"get_repository",
|
|
"get_service",
|
|
"get_settings",
|
|
"read_csrf_token",
|
|
"request_id",
|
|
"require_admin",
|
|
"require_principal",
|
|
"session_token",
|
|
"set_csrf_cookie",
|
|
"set_session_cookie",
|
|
"validation_fields",
|
|
]
|