refactor: form-core wiring, JWT sessions, slowapi, packaging, frontend rewrite, test declutter

This commit is contained in:
Jonas Miederer 2026-09-28 18:03:07 +02:00
parent 06ab25fb47
commit 03af41e837
74 changed files with 6089 additions and 5183 deletions

View file

@ -126,17 +126,14 @@ def consume_magic(
session: Annotated[Session, Depends(get_db)],
):
try:
user_session = service.consume_magic_token(session, data.token)
user, session_token = service.consume_magic_token(session, data.token)
except (
service.InvalidTokenError,
service.ExpiredTokenError,
service.UsedTokenError,
) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
user = service.get_user(session, user_session.user_id)
if user is None: # pragma: no cover - consume already validated the user
raise HTTPException(status_code=400, detail="unknown token")
_set_session_cookie(response, user_session.id)
_set_session_cookie(response, session_token)
return _read_me(user)
@ -151,11 +148,8 @@ def logout(
response: Response,
session: Annotated[Session, Depends(get_db)],
):
# Revoke the server-side row when the cookie maps to a live session;
# always clear the client cookie.
session_id = request.cookies.get(SESSION_COOKIE)
if session_id:
service.revoke_session(session, session_id)
# Stateless sessions: nothing server-side to revoke — always clear
# the client cookie.
response.delete_cookie(SESSION_COOKIE, path="/")
return MessageRead(message="logged out")

View file

@ -19,13 +19,10 @@ SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds())
def get_optional_user(
request: Request, session: Annotated[Session, Depends(get_db)]
) -> User | None:
session_id = request.cookies.get(SESSION_COOKIE)
if not session_id:
token = request.cookies.get(SESSION_COOKIE)
if not token:
return None
record = service.get_valid_session(session, session_id)
if record is None:
return None
return service.get_user(session, record.user_id)
return service.get_token_user(session, token)
def get_current_user(request: Request, session: Annotated[Session, Depends(get_db)]) -> User:

View file

@ -9,8 +9,10 @@ fonts, no external images (many clients block them / track opens).
from __future__ import annotations
import logging
import smtplib
from email.message import EmailMessage
from pathlib import Path
from jinja2 import Template
from app.settings import settings
@ -22,67 +24,62 @@ BLUE_LIGHT = "#eef4fb"
INK = "#162f4c"
MUTED = "#5b6b7c"
_TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "magic_link.html"
_template_cache: Template | None = None
def _template() -> Template:
global _template_cache
if _template_cache is None:
_template_cache = Template(_TEMPLATE_PATH.read_text(encoding="utf-8"))
return _template_cache
def magic_link(token: str) -> str:
return f"{settings.frontend_url.rstrip('/')}/login?token={token}"
def _page(title: str, intro: str, button_label: str, button_url: str | None, note: str) -> str:
"""Branded HTML mail body (table layout, inline styles, no images)."""
button = ""
if button_url:
button = (
'<table role="presentation" cellpadding="0" cellspacing="0" '
'style="margin:24px auto 8px auto;"><tr>'
f'<td bgcolor="{BLUE}" style="border-radius:8px;">'
f'<a href="{button_url}" '
'style="display:inline-block;padding:12px 28px;font-family:-apple-system,'
"Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;"
"font-weight:600;color:#ffffff;text-decoration:none;"
f'border-radius:8px;">{button_label}</a></td></tr></table>'
)
return f"""<!DOCTYPE html>
<html lang="de">
<body style="margin:0;padding:0;background-color:{BLUE_LIGHT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="background-color:{BLUE_LIGHT};padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
style="max-width:520px;width:100%;font-family:Georgia,'Times New Roman',serif;
border-radius:12px;overflow:hidden;border:1px solid #d9e6f5;">
<tr>
<td bgcolor="{BLUE}" style="padding:20px 28px;">
<span style="color:#ffffff;font-size:18px;font-weight:700;
letter-spacing:0.02em;">Kirche Einfach</span>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" style="padding:28px 28px 24px 28px;">
<h1 style="margin:0 0 12px 0;font-size:22px;line-height:1.3;
color:{INK};">{title}</h1>
<p style="margin:0;font-family:-apple-system,Segoe UI,Roboto,
Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;
color:{MUTED};">{intro}</p>
{button}
</td>
</tr>
</table>
<p style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
font-size:12px;color:{MUTED};margin:16px 0 0 0;max-width:520px;">
{note}
</p>
</td></tr>
</table>
</body>
</html>"""
"""Branded HTML mail body (jinja template, byte-identical to the old f-string)."""
return _template().render(
title=title,
intro=intro,
button_label=button_label,
button_url=button_url,
note=note,
blue=BLUE,
blue_light=BLUE_LIGHT,
ink=INK,
muted=MUTED,
)
def _send(message: EmailMessage) -> None:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
smtp.starttls()
if settings.smtp_username:
smtp.login(settings.smtp_username, settings.smtp_password)
smtp.send_message(message)
"""Deliver via fastapi-mail (same never-raise contract at call sites)."""
import asyncio
from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType
config = ConnectionConfig(
MAIL_USERNAME=settings.smtp_username,
MAIL_PASSWORD=settings.smtp_password,
MAIL_FROM=settings.mail_from or settings.contact_to or message["To"],
MAIL_PORT=settings.smtp_port,
MAIL_SERVER=settings.smtp_host,
MAIL_STARTTLS=True,
MAIL_SSL_TLS=False,
USE_CREDENTIALS=bool(settings.smtp_username),
)
html = message.get_body(preferencelist=("html",))
plain = message.get_body(preferencelist=("plain",))
outgoing = MessageSchema(
subject=str(message["Subject"]),
recipients=[str(message["To"])],
body=plain.get_content() if plain else "",
html=html.get_content() if html else "",
subtype=MessageType.html,
)
asyncio.run(FastMail(config).send_message(outgoing))
def _branded_mail(
@ -142,7 +139,7 @@ def send_magic_link(to_email: str, link: str) -> bool:
)
try:
_send(message)
except (OSError, smtplib.SMTPException):
except Exception: # noqa: BLE001 -- never-raise contract: delivery must not 500
logger.warning("magic-link delivery failed for %s", to_email)
return False
logger.info("magic link mailed to %s", to_email)
@ -167,7 +164,7 @@ def send_access_notification(request_id: str, name: str, email: str) -> bool:
)
try:
_send(message)
except (OSError, smtplib.SMTPException):
except Exception: # noqa: BLE001 -- best-effort notification, never raises
logger.warning("access-request notification failed for %s", request_id)
return False
return True

View file

@ -1,7 +1,12 @@
"""Access requests, magic-link tokens, and sessions (DB via function args).
"""Access requests, magic-link tokens, and stateless JWT sessions.
Keep API handlers thin: all persistence lives here. Tokens and session ids
are bearer secrets — log user/request ids only, never secret values.
Keep API handlers thin: all persistence lives here. Tokens and session
JWTs are bearer secrets — log user/request ids only, never secret values.
Sessions are stateless HS256 JWTs (sub=user.id, email, role, tenant_id,
iat, exp=30d, jti) in the HttpOnly session cookie. No session table:
logout clears the cookie; role/tenant changes apply at next login (or
immediately for deleted users — the user row is reloaded per request).
"""
from __future__ import annotations
@ -9,12 +14,14 @@ from __future__ import annotations
import hashlib
import logging
import secrets
import uuid
from datetime import UTC, datetime, timedelta
from uuid import UUID
import jwt
from sqlmodel import Session, func, select
from app.models import AccessRequest, MagicLinkToken, User, UserSession
from app.models import AccessRequest, MagicLinkToken, User
from app.models.base import utcnow
from app.settings import settings
@ -235,8 +242,43 @@ def _aware(value: datetime) -> datetime:
return value
def consume_magic_token(session: Session, token: str) -> UserSession:
"""Single-use consume → session row. used_at is set before creation."""
def _jwt_secret() -> str:
secret = settings.jwt_secret
if not secret:
raise RuntimeError("JWT_SECRET is empty — set it in .env")
return secret
def mint_session_token(user: User) -> str:
"""Issue a stateless session JWT for a user (30-day expiry)."""
now = utcnow()
return jwt.encode(
{
"sub": str(user.id),
"email": user.email,
"role": user.role,
"tenant_id": user.tenant_id,
"iat": int(now.timestamp()),
"exp": int((now + SESSION_TTL).timestamp()),
"jti": uuid.uuid4().hex,
},
_jwt_secret(),
algorithm="HS256",
)
def decode_session_token(token: str) -> dict:
"""Verify a session JWT → claims. Expired/forged → token errors."""
try:
return jwt.decode(token.strip(), _jwt_secret(), algorithms=["HS256"])
except jwt.ExpiredSignatureError as exc:
raise ExpiredTokenError("session expired") from exc
except jwt.InvalidTokenError as exc:
raise InvalidTokenError("invalid session") from exc
def consume_magic_token(session: Session, token: str) -> tuple[User, str]:
"""Single-use consume → (user, session JWT). used_at is set first."""
record = session.exec(
select(MagicLinkToken).where(MagicLinkToken.token_hash == hash_token(token.strip()))
).first()
@ -252,33 +294,23 @@ def consume_magic_token(session: Session, token: str) -> UserSession:
# Mark used first: a reused token can never mint a second session.
record.used_at = utcnow()
session.add(record)
user_session = UserSession(
id=secrets.token_urlsafe(32),
user_id=user.id,
expires_at=utcnow() + SESSION_TTL,
)
session.add(user_session)
session.commit()
session.refresh(user_session)
session.refresh(user)
logger.info("magic token consumed for user %s", user.id)
return user_session
return user, mint_session_token(user)
def get_valid_session(session: Session, session_id: str) -> UserSession | None:
record = session.get(UserSession, session_id)
if record is None or record.revoked_at is not None:
def get_token_user(session: Session, token: str) -> User | None:
"""User for a session JWT, or None (expired/forged/deleted user)."""
try:
claims = decode_session_token(token)
except (InvalidTokenError, ExpiredTokenError):
return None
if _aware(record.expires_at) <= utcnow():
try:
user_id = UUID(str(claims.get("sub", "")))
except ValueError:
return None
return record
def revoke_session(session: Session, session_id: str) -> None:
record = session.get(UserSession, session_id)
if record is not None and record.revoked_at is None:
record.revoked_at = utcnow()
session.add(record)
session.commit()
return session.get(User, user_id)
def bootstrap_admin(session: Session, email: str) -> tuple[User, str]:

View file

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="de">
<body style="margin:0;padding:0;background-color:{{ blue_light }};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="background-color:{{ blue_light }};padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
style="max-width:520px;width:100%;font-family:Georgia,'Times New Roman',serif;
border-radius:12px;overflow:hidden;border:1px solid #d9e6f5;">
<tr>
<td bgcolor="{{ blue }}" style="padding:20px 28px;">
<span style="color:#ffffff;font-size:18px;font-weight:700;
letter-spacing:0.02em;">Kirche Einfach</span>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" style="padding:28px 28px 24px 28px;">
<h1 style="margin:0 0 12px 0;font-size:22px;line-height:1.3;
color:{{ ink }};">{{ title }}</h1>
<p style="margin:0;font-family:-apple-system,Segoe UI,Roboto,
Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;
color:{{ muted }};">{{ intro }}</p>
{% if button_url %}
<table role="presentation" cellpadding="0" cellspacing="0"
style="margin:24px auto 8px auto;"><tr>
<td bgcolor="{{ blue }}" style="border-radius:8px;">
<a href="{{ button_url }}"
style="display:inline-block;padding:12px 28px;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ button_label }}</a>
</td>
</tr></table>
{% endif %}
</td>
</tr>
</table>
<p style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
font-size:12px;color:{{ muted }};margin:16px 0 0 0;max-width:520px;">
{{ note }}
</p>
</td></tr>
</table>
</body>
</html>

View file

@ -17,12 +17,13 @@ from pathlib import Path
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from sqlmodel import Session
from app.auth.deps import get_current_user, require_admin
from app.db import get_db
from app.middleware import limiter, run_limit
from app.models import User
from app.settings import settings
@ -50,6 +51,7 @@ from .schemas import (
FlowVersionCreate,
FlowVersionRead,
InputSchemaRead,
NamedRunRead,
RunRead,
RunRequest,
SyncReport,
@ -58,6 +60,7 @@ from .sync import lfx_id_for
flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
runs_router = APIRouter(prefix="/api/runs", tags=["runs"])
logger = logging.getLogger(__name__)
@ -211,7 +214,9 @@ def _resolve_version(session: Session, flow_id: UUID, version_no: int | None):
@flows_router.post("/{flow_id}/run", response_model=RunRead)
@limiter.limit(run_limit)
async def run_flow(
request: Request,
flow_id: UUID,
data: RunRequest,
session: Annotated[Session, Depends(get_db)],
@ -279,6 +284,18 @@ async def _run_stream(
return StreamingResponse(event_gen(), media_type="text/event-stream")
@runs_router.get("", response_model=list[NamedRunRead])
def list_all_runs(
session: Annotated[Session, Depends(get_db)],
user: Annotated[User, Depends(get_current_user)],
):
"""All runs of the session tenant with flow names (single query)."""
return [
NamedRunRead(flow_name=name, run=_read_run(run))
for run, name in store.list_all_runs(session, user.tenant_id)
]
@flows_router.get("/{flow_id}/runs", response_model=list[RunRead])
def list_runs(
flow_id: UUID,

View file

@ -2,12 +2,19 @@
Consolidates base_url stripping, header building, per-request client
construction, and HTTPError → domain-error mapping. Subclasses only
declare their error types and endpoint calls.
declare their error types and endpoint calls. Transport-level failures
(connect/timeout, never 4xx) retry with exponential backoff via tenacity.
"""
from __future__ import annotations
import httpx
from tenacity import (
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
class BaseApiClient:
@ -25,3 +32,13 @@ class BaseApiClient:
return httpx.AsyncClient(
base_url=self.base_url, timeout=self.timeout, headers=self._headers()
)
def transport_retry(func): # type: ignore[no-untyped-def]
"""Retry transport errors 3x with backoff; HTTP status errors pass through."""
return retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=0.5, max=4),
retry=retry_if_exception_type((httpx.TransportError, httpx.TimeoutException)),
reraise=True,
)(func)

View file

@ -10,7 +10,6 @@ import hashlib
import json
import os
import re
import tempfile
from pathlib import Path
FLOWS_GLOB = "*.json"
@ -20,18 +19,32 @@ FLOWS_GLOB = "*.json"
#: validated, or shown in the frontend.
IGNORE_FILENAME = ".flowsignore"
# mtime-keyed cache: the watcher re-reads per event, the file rarely changes.
_ignore_cache: dict[str, tuple[float, set[str]]] = {}
def ignored_stems(flows_dir: str | Path) -> set[str]:
"""Stems excluded from the pipeline (empty when no ignore file)."""
key = str(Path(flows_dir))
try:
mtime = (Path(flows_dir) / IGNORE_FILENAME).stat().st_mtime
except OSError:
_ignore_cache.pop(key, None)
return set()
cached = _ignore_cache.get(key)
if cached is not None and cached[0] == mtime:
return cached[1]
try:
text = (Path(flows_dir) / IGNORE_FILENAME).read_text(encoding="utf-8")
except OSError:
return set()
return {
stems = {
line.strip()
for line in text.splitlines()
if line.strip() and not line.strip().startswith("#")
}
_ignore_cache[key] = (mtime, stems)
return stems
def filename_stem(name: str) -> str:
@ -97,25 +110,16 @@ def find_reuse_path(flows_dir: str | Path, doc: dict) -> tuple[Path | None, bool
def write_flow_bytes(target: Path, doc: dict) -> Path:
"""Write a flow document to an explicit path atomically (tmp + rename).
The file ends up world-readable (0o644): mkstemp creates 0o600, which
would lock out the other containers (Langflow/LFX run as different
users and crash on unreadable seed files).
The file ends up world-readable (0o644): atomicwrites follows the
umask, which would lock out the other containers (Langflow/LFX run
as different users and crash on unreadable seed files).
"""
from atomicwrites import atomic_write
target.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
dir=str(target.parent), prefix=f".{target.stem}-", suffix=".tmp"
)
try:
with os.fdopen(fd, "wb") as tmp:
tmp.write(canonical_bytes(doc))
os.replace(tmp_name, target)
os.chmod(target, 0o644)
except BaseException:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
with atomic_write(target, mode="wb", overwrite=True) as tmp:
tmp.write(canonical_bytes(doc))
os.chmod(target, 0o644)
return target

View file

@ -8,7 +8,7 @@ from __future__ import annotations
import httpx
from .base_client import BaseApiClient
from .base_client import BaseApiClient, transport_retry
class LangflowError(Exception):
@ -29,6 +29,7 @@ class LangflowClient(BaseApiClient):
def __init__(self, base_url: str, api_key: str = "", timeout: float = 60.0) -> None:
super().__init__(base_url, timeout=timeout, api_key=api_key)
@transport_retry
async def list_flows(self) -> list[dict]:
"""List flows (header mode keeps payloads small)."""
try:
@ -44,6 +45,7 @@ class LangflowClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc
@transport_retry
async def export_flow(self, langflow_flow_id: str) -> dict:
"""Export one flow as Langflow JSON (FlowRead: id/name/description/data)."""
try:
@ -58,6 +60,7 @@ class LangflowClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc
@transport_retry
async def upsert_flow(self, doc: dict) -> dict:
"""Create or update a flow in Langflow by name (authoring UI refresh).

View file

@ -6,7 +6,7 @@ from dataclasses import dataclass
import httpx
from .base_client import BaseApiClient
from .base_client import BaseApiClient, transport_retry
class LfxError(Exception):
@ -56,6 +56,7 @@ class LfxClient(BaseApiClient):
def __init__(self, base_url: str, timeout: float = 120.0, api_key: str | None = None) -> None:
super().__init__(base_url, timeout=timeout, api_key=api_key)
@transport_retry
async def health(self) -> dict:
try:
async with self._client() as client:
@ -65,6 +66,7 @@ class LfxClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
@transport_retry
async def list_flows(self) -> list[dict]:
try:
async with self._client() as client:
@ -76,6 +78,7 @@ class LfxClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
@transport_retry
async def upload_flow(
self, flow_json: dict, *, flow_id: str | None = None, replace: bool = False
) -> LfxUploadResult:
@ -140,6 +143,7 @@ class LfxClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
@transport_retry
async def flow_info(self, lfx_flow_id: str) -> dict:
try:
async with self._client() as client:
@ -151,6 +155,7 @@ class LfxClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
@transport_retry
async def delete_flow(self, lfx_flow_id: str) -> None:
try:
async with self._client() as client:

View file

@ -76,6 +76,13 @@ class RunRead(BaseModel):
finished_at: datetime | None
class NamedRunRead(BaseModel):
"""A run with its flow name (GET /api/runs aggregate, kills N+1)."""
flow_name: str
run: RunRead
class ApprovalCreate(BaseModel):
"""Callback contract: a flow reports a terminal approval state."""

View file

@ -129,12 +129,18 @@ def validate_flow_json(flow_json: dict) -> None:
raise InvalidError("langflow_json.data must contain 'nodes' and 'edges' lists")
def create_version(session: Session, flow_id: UUID, data: FlowVersionCreate) -> FlowVersion:
def create_version(
session: Session,
flow_id: UUID,
data: FlowVersionCreate,
*,
version_no: int | None = None,
) -> FlowVersion:
get_definition(session, flow_id)
validate_flow_json(data.langflow_json)
version = FlowVersion(
flow_id=flow_id,
version_no=next_version_no(session, flow_id),
version_no=version_no if version_no is not None else next_version_no(session, flow_id),
langflow_json=data.langflow_json,
author=data.author,
# Single choke point: sync, import, and API creation all cache the
@ -256,6 +262,18 @@ def list_runs(session: Session, flow_id: UUID, tenant_id: str | None = None) ->
return list(session.exec(query).all())
def list_all_runs(session: Session, tenant_id: str) -> list[tuple[FlowRun, str]]:
"""All runs of a tenant with flow names (single query, kills N+1)."""
rows = session.exec(
select(FlowRun, FlowDefinition.name)
.join(FlowVersion, FlowVersion.id == FlowRun.version_id)
.join(FlowDefinition, FlowDefinition.id == FlowVersion.flow_id)
.where(FlowRun.tenant_id == tenant_id)
.order_by(FlowRun.created_at.desc())
).all()
return [(run, name) for run, name in rows]
def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask:
run = session.get(FlowRun, data.run_id)
if run is None:

View file

@ -64,8 +64,8 @@ async def sync_document(
"skipped" (content unchanged). Raises store.ConflictError on name
clashes, store.InvalidError on invalid JSON, LfxError when deploy fails.
With deploy=False a version is recorded without touching LFX.
Validation lives in store.create_version (single choke point).
"""
store.validate_flow_json(doc)
name = doc.get("name") or Path(source_filename).stem
with sync_lock:
definition = store.get_definition_by_source_path(session, source_filename)
@ -102,9 +102,8 @@ async def sync_document(
session,
definition.id,
FlowVersionCreate(langflow_json=doc, author=author),
version_no=version_no,
)
if version.version_no != version_no: # pragma: no cover - lock prevents this
raise store.ConflictError(f"concurrent version write for flow {definition.id}")
version = store.mark_deployed(session, version, uploaded.id)
if latest is not None and latest.lfx_flow_id and latest.lfx_flow_id != uploaded.id:
try:

View file

@ -4,13 +4,16 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from starlette.middleware.body_limit import RequestBodyLimitMiddleware
from .auth.api import access_router, auth_router
from .flows.api import approvals_router, flows_router
from .flows.api import approvals_router, flows_router, runs_router
from .flows.sync import format_report, run_sync_blocking, watch_forever
from .middleware import RunThrottleMiddleware, SecurityHeadersMiddleware
from .middleware import SecurityHeadersMiddleware, limiter
from .settings import settings
logger = logging.getLogger(__name__)
@ -64,12 +67,23 @@ app = FastAPI(
# entrypoint.sh) trusts its forwarding headers so Secure cookies, client
# IPs (throttle) and absolute URLs are correct. The API is not directly
# internet-reachable (proxy-only), so trusting them is safe.
app.state.limiter = limiter
app.add_middleware(RequestBodyLimitMiddleware, max_body_size=settings.max_body_bytes)
app.add_middleware(RunThrottleMiddleware)
app.add_middleware(SlowAPIMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
@app.exception_handler(RateLimitExceeded)
async def _throttle_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse:
response = JSONResponse({"detail": "too many runs, try again later"}, status_code=429)
retry_after = getattr(exc, "retry_after", None) or "60"
response.headers["Retry-After"] = str(retry_after)
return response
app.include_router(flows_router)
app.include_router(approvals_router)
app.include_router(runs_router)
app.include_router(access_router)
app.include_router(auth_router)

View file

@ -1,79 +1,59 @@
"""Edge hardening: run throttling, security headers.
"""Edge hardening: run throttling (slowapi), security headers (secure).
Body caps come from Starlette's RequestBodyLimitMiddleware (wired in
main.py). All limits are settings-driven so tests can shrink them. State
is in-process memory: correct for the single-replica SQLite deployment;
revisit with Redis if the API ever scales horizontally.
main.py). The run throttle is settings-driven so tests can shrink it.
State is in-process memory: correct for the single-replica SQLite
deployment; revisit with Redis if the API ever scales horizontally.
"""
from __future__ import annotations
import time
from collections import defaultdict
from slowapi import Limiter
from slowapi.util import get_remote_address
from starlette.datastructures import MutableHeaders
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from app.settings import settings
# POST .../run is the expensive endpoint (LFX execution, LLM cost):
# per client IP and minute.
RUN_PATH_SUFFIX = "/run"
THROTTLE_WINDOW_SECONDS = 60.0
# per client IP and minute, limit read live from settings so tests can
# shrink it without re-creating the app.
limiter = Limiter(
key_func=get_remote_address,
storage_uri="memory://",
default_limits=[],
)
_hits: dict[str, list[float]] = defaultdict(list)
def run_limit() -> str:
return f"{settings.run_throttle_per_minute}/minute"
def reset_throttle() -> None:
"""Drop all counters (tests)."""
_hits.clear()
def _client_ip(request: Request) -> str:
# Behind the TLS proxy ProxyHeadersMiddleware has already rewritten
# scope["client"] from X-Forwarded-For.
if request.client is not None:
return request.client.host
forwarded = request.headers.get("x-forwarded-for", "")
return forwarded.split(",")[0].strip() or "unknown"
class RunThrottleMiddleware:
"""Per-IP sliding window on POST .../run (429 + Retry-After)."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if (
scope["type"] != "http"
or scope.get("method") != "POST"
or not scope.get("path", "").endswith(RUN_PATH_SUFFIX)
):
await self.app(scope, receive, send)
return
request = Request(scope)
now = time.monotonic()
window_start = now - THROTTLE_WINDOW_SECONDS
bucket = [t for t in _hits[_client_ip(request)] if t > window_start]
if len(bucket) >= settings.run_throttle_per_minute:
retry_after = max(1, int(bucket[0] + THROTTLE_WINDOW_SECONDS - now))
response = JSONResponse({"detail": "too many runs, try again later"}, status_code=429)
response.headers["Retry-After"] = str(retry_after)
await response(scope, receive, send)
return
bucket.append(now)
_hits[_client_ip(request)] = bucket
await self.app(scope, receive, send)
try:
limiter._storage.reset() # type: ignore[attr-defined]
except AttributeError:
pass
class SecurityHeadersMiddleware:
"""Baseline response headers (TLS/HSTS live on the proxy)."""
"""Baseline response headers (TLS/HSTS live on the proxy).
Built with the ``secure`` package; values stay byte-identical to the
previous hand-rolled version (see test_middleware).
"""
def __init__(self, app: ASGIApp) -> None:
import secure as secure_lib
self.app = app
self.secure_headers = secure_lib.Secure(
xcto=secure_lib.XContentTypeOptions().nosniff(),
xfo=secure_lib.XFrameOptions().sameorigin(),
referrer=secure_lib.ReferrerPolicy().strict_origin_when_cross_origin(),
csp=secure_lib.ContentSecurityPolicy().frame_ancestors("'self'"),
)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
@ -83,10 +63,8 @@ class SecurityHeadersMiddleware:
async def secured(message: Message) -> None:
if message["type"] == "http.response.start":
headers = MutableHeaders(raw=message.setdefault("headers", []))
headers["X-Content-Type-Options"] = "nosniff"
headers["X-Frame-Options"] = "SAMEORIGIN"
headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
headers["Content-Security-Policy"] = "frame-ancestors 'self'"
for key, value in self.secure_headers.headers.items():
headers[key] = value
await send(message)
await self.app(scope, receive, secured)

View file

@ -1,5 +1,5 @@
from .flow import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
from .user import AccessRequest, MagicLinkToken, User, UserSession
from .user import AccessRequest, MagicLinkToken, User
__all__ = [
"AccessRequest",
@ -9,5 +9,4 @@ __all__ = [
"FlowVersion",
"MagicLinkToken",
"User",
"UserSession",
]

View file

@ -47,19 +47,7 @@ class MagicLinkToken(SQLModel, table=True):
user_id: UUID = Field(foreign_key="users.id", index=True)
token_hash: str = Field(index=True, unique=True, max_length=64)
expires_at: datetime = Field(sa_column=ts_column())
# Set before the session row is created, so a reused token can never
# Set before the session JWT is minted, so a reused token can never
# mint a second session.
used_at: datetime | None = Field(default=None, sa_column=ts_column())
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column())
class UserSession(SQLModel, table=True):
"""An opaque session id in the DB (cookie value). Revocable, expirable."""
__tablename__ = "user_sessions"
id: str = Field(primary_key=True, max_length=128)
user_id: UUID = Field(foreign_key="users.id", index=True)
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column())
expires_at: datetime = Field(sa_column=ts_column())
revoked_at: datetime | None = Field(default=None, sa_column=ts_column())

View file

@ -42,7 +42,9 @@ class Settings(BaseSettings):
run_throttle_per_minute: int = 60
# --- access management (magic-link auth, see app/auth) ---
# Public frontend URL (magic links point at /login?token= there).
# Secret signing session JWTs (stateless cookies). Long random value;
# empty fails closed at login time — set it in prod (.env JWT_SECRET).
jwt_secret: str = "" # Public frontend URL (magic links point at /login?token= there).
frontend_url: str = "http://localhost:3000"
# Comma-separated emails that become admins (role assignment + bootstrap).
admin_emails: str = ""