refactor: form-core wiring, JWT sessions, slowapi, packaging, frontend rewrite, test declutter
This commit is contained in:
parent
06ab25fb47
commit
03af41e837
74 changed files with 6089 additions and 5183 deletions
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
42
backend/app/auth/templates/magic_link.html
Normal file
42
backend/app/auth/templates/magic_link.html
Normal 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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue