The compute proof of concept had never been run with the agent as umbra-poc. Running it that way found three defects, all the same shape: a check that passed because its mechanism did nothing. The demonstration never reached the agent at all. It held the pid it spawned, which under `sudo -n -u` is the sudo wrapper - sudo forks the agent before exec'ing. Signalling that killed the wrapper and left the worker running, renewing its lease and holding the job indefinitely, under a printed note saying "exactly like a power cut". An unprivileged process cannot signal another account's process at all, and kill(2) on a process group succeeds as soon as one member is signalled, so the wrapper's death read as the agent's. Every run leaked an agent. Lease recovery was a diagnosis rather than a mechanism. sweep_expired_leases documented itself as "called by the controller's own timer" and there was no timer; a killed node's job came back only when somebody noticed a stalled job and posted to an administrative route by hand. The proof of concept swept by hand and recorded the result as automatic. app.py now runs the loop for the life of the application, building the compute repository at startup so a restart mid-job still recovers it. The power cut is now the agent's own: os._exit(137) the instant it claims a job, before running it, because a process that does not own the agent cannot signal it. A clean stop goes through a stop_file the agent polls, for the same reason. Liveness is measured by finding the worker's own pid in /proc matched on its config path, sampled repeatedly and accumulated - the wrapper and the worker are not simultaneous, so a single sample finds only the wrapper. The node key travels by `sudo --preserve-env` rather than as VAR=value in argv, because argv is world-readable through /proc/<pid>/cmdline and the environment is not. The demonstration checks whether the key will survive before starting and falls back to a pipe, which node_agent.py --node-key-stdin accepts. Neither a file nor argv ever holds it. Eight tests, each verified to fail against a mutation of the code it covers: three for the sweeper (it must return an expired lease, must survive a failing round, must not invent a repository it has no database for) and five driving a real agent process against a stub controller (stops on the stop file, exits 137 holding an uncompleted job, takes the key from both routes and neither, reports an empty pipe as an empty pipe). 448 pass with a database, 421 without. The deployment database is untouched: users=2 orgs=3 projects=0 audit=43, no compute tables.
670 lines
28 KiB
Python
670 lines
28 KiB
Python
"""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
|
|
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),
|
|
}
|
|
},
|
|
)
|
|
|
|
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_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"])
|
|
# 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",
|
|
]
|