Files
UmbraTrans/umbratrans_web/app.py
umbra audio b474995414 Add phase-1 account management backend slice
Implements the account and user-management system behind the new web UI
against development repositories only. Nothing here connects to, reads, or
modifies the production umbracontrol database, and no account is created in
any approved environment.

Account service and API:
- Local temporary-password and OIDC enrollment, separated by type.
- Argon2id credentials, must-change enforcement, server-side sessions with
  hashed tokens, idle and absolute deadlines, rotation and revocation.
- Administrator account management: list, get, create, suspend, reactivate,
  deactivate, revoke sessions, org-scoped audit view.
- Hidden-prompt operator command for the first administrator, refusing
  production mode and refusing to run without a TTY.

Temporary passwords are never accepted from a client. The request schema has
no password field and refuses unknown fields; the administrator path generates
the one-time password in process, hashes it immediately, and returns it once in
the creation response. The account can reach no route, including
/api/auth/me, until it replaces that password, after which the one-time value
no longer authenticates.

Security posture: fail-closed configuration, Secure cookies in production,
double-submit CSRF, constant-time token comparison, generic authentication
errors, audit detail allow-listing, and logger-tree secret redaction.

Reviewed but unapplied: migrations/0001_phase1_identity.sql adds
users.username, a separate user_credentials table, and additive session
columns, with a rollback and open review questions. The PostgreSQL adapter is
deliberately absent, so the application refuses to start in production rather
than run against an unreviewed store.

Verification: 169 tests pass (148 new, 21 pre-existing); ruff check, ruff
format, compileall, and make web-check are clean.
2026-09-26 08:29:23 -07:00

449 lines
16 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 logging
from datetime import datetime, timezone
from http.cookies import SimpleCookie
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 starlette.datastructures import MutableHeaders
from starlette.middleware.trustedhost import TrustedHostMiddleware
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from .config import Settings, load_settings
from .errors import (
AuthenticationRequiredError,
ConfigurationError,
CsrfError,
ForbiddenError,
InvalidCredentialsError,
PasswordChangeRequiredError,
PasswordPolicyError,
UmbraWebError,
)
from .models import Principal
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
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"})
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")
method = scope.get("method", "GET").upper()
if is_api and self._settings.require_csrf and method in UNSAFE_METHODS and path not in CSRF_EXEMPT_PATHS:
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"
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 create_app(
*,
settings: Settings | None = None,
repository: AccountRepository | None = None,
service: AccountService | None = None,
) -> FastAPI:
"""Build the phase-1 application.
Production settings can only be constructed with the PostgreSQL adapter,
which is not part of this slice, so this factory cannot start an
unaudited production process.
"""
resolved_settings = settings or load_settings()
if resolved_settings.is_production and repository is None:
raise ConfigurationError(
"production_repository_missing",
"Production requires an explicit PostgreSQL repository adapter, which is not part of this slice.",
)
resolved_repository = build_repository(resolved_settings, repository=repository)
resolved_service = service or AccountService(resolved_repository, resolved_settings)
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,
)
app.state.settings = resolved_settings
app.state.repository = resolved_repository
app.state.service = 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_uadmin import router as uadmin_router
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(uadmin_router, prefix="/api/uadmin", tags=["uadmin"])
@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"]),
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,
)
return app
__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",
]