fix: authorize flow management, gate runs, token-gate callbacks, strict tenants

This commit is contained in:
Jonas Miederer 2026-09-14 20:12:17 +02:00
parent cfcf0e8bd6
commit 94e5e53092
21 changed files with 662 additions and 160 deletions

View file

@ -1,19 +1,27 @@
"""Flows + approvals HTTP API."""
"""Flows + approvals HTTP API.
Auth model: flow *management* (create/version/deploy/sync/import) is
admin-only. Browsing (list/get/input-schema) is public so the website
renders. Runs, run reads and approval reads require login and are scoped
to the session tenant. Flow *callbacks* (LFX has no session) carry a
shared X-Callback-Token instead.
"""
from __future__ import annotations
import json
import secrets
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
from typing import Annotated
from uuid import UUID
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlmodel import Session
from app.auth.deps import get_optional_user, resolve_tenant
from app.auth.deps import get_current_user, require_admin
from app.db import get_engine, get_session
from app.models import User
from app.settings import settings
@ -127,7 +135,11 @@ def _read_task(t) -> ApprovalRead:
@flows_router.post("", response_model=FlowDefinitionRead, status_code=201)
def create_flow(data: FlowDefinitionCreate, session: Annotated[Session, Depends(get_db)]):
def create_flow(
data: FlowDefinitionCreate,
session: Annotated[Session, Depends(get_db)],
_admin: Annotated[User, Depends(require_admin)],
):
try:
return _read_definition(store.create_definition(session, data))
except store.ConflictError as exc:
@ -145,6 +157,7 @@ def list_flows(session: Annotated[Session, Depends(get_db)]):
async def sync_flows(
session: Annotated[Session, Depends(get_db)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
_admin: Annotated[User, Depends(require_admin)],
):
"""Reconcile backend/flows/*.json into the DB + LFX (add/update/delete)."""
try:
@ -163,7 +176,10 @@ def get_flow(flow_id: UUID, session: Annotated[Session, Depends(get_db)]):
@flows_router.post("/{flow_id}/versions", response_model=FlowVersionRead, status_code=201)
def create_version(
flow_id: UUID, data: FlowVersionCreate, session: Annotated[Session, Depends(get_db)]
flow_id: UUID,
data: FlowVersionCreate,
session: Annotated[Session, Depends(get_db)],
_admin: Annotated[User, Depends(require_admin)],
):
try:
return _read_version(store.create_version(session, flow_id, data))
@ -174,7 +190,11 @@ def create_version(
@flows_router.get("/{flow_id}/versions", response_model=list[FlowVersionRead])
def list_versions(flow_id: UUID, session: Annotated[Session, Depends(get_db)]):
def list_versions(
flow_id: UUID,
session: Annotated[Session, Depends(get_db)],
_admin: Annotated[User, Depends(require_admin)],
):
try:
return [_read_version(v) for v in store.list_versions(session, flow_id)]
except store.NotFoundError as exc:
@ -187,6 +207,7 @@ async def deploy_version(
version_no: int,
session: Annotated[Session, Depends(get_db)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
_admin: Annotated[User, Depends(require_admin)],
):
try:
version = store.get_version_by_no(session, flow_id, version_no)
@ -223,30 +244,32 @@ async def run_flow(
data: RunRequest,
session: Annotated[Session, Depends(get_db)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
user: Annotated[User | None, Depends(get_optional_user)],
user: Annotated[User, Depends(get_current_user)],
stream: bool = Query(default=False),
):
# Tenant comes from the session; the body param stays as a deprecated
# fallback for unauthenticated callers.
data = data.model_copy(update={"tenant_id": resolve_tenant(user, data.tenant_id)})
# Tenant, callback URL and continuation always come from the server:
# the session, the settings, and the admin-authored definition.
tenant_id = user.tenant_id
try:
definition = store.get_definition(session, flow_id)
version = _resolve_version(session, flow_id, data.version_no)
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except store.InvalidError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if stream:
return await _run_stream(session, lfx, version, data)
return await _run_stream(session, lfx, version, data, user, definition)
try:
run = await runner.execute_run(
session,
lfx,
version,
payload=data.input,
tenant_id=data.tenant_id,
tenant_id=tenant_id,
session_id=data.session_id,
callback_base_url=data.callback_base_url or settings.callback_base_url,
continuation_flow_id=data.continuation_flow_id,
callback_base_url=settings.callback_base_url,
continuation_flow_id=definition.continuation_flow_id,
callback_token=settings.callback_token,
)
except LfxNotFound as exc:
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
@ -257,21 +280,24 @@ async def run_flow(
return _read_run(run)
async def _run_stream(session: Session, lfx: LfxClient, version, data: RunRequest):
async def _run_stream(
session: Session, lfx: LfxClient, version, data: RunRequest, user: User, definition
):
if not version.lfx_flow_id:
raise HTTPException(status_code=422, detail=f"version {version.id} is not deployed to LFX")
run = store.create_run(
session,
version,
input=data.input,
tenant_id=data.tenant_id,
tenant_id=user.tenant_id,
session_id=data.session_id,
)
envelope = runner.build_envelope(
payload=data.input,
run_id=run.id,
callback_base_url=data.callback_base_url or settings.callback_base_url,
continuation_flow_id=data.continuation_flow_id,
callback_base_url=settings.callback_base_url,
continuation_flow_id=definition.continuation_flow_id,
callback_token=settings.callback_token,
)
run_id = run.id
lfx_flow_id = version.lfx_flow_id
@ -314,21 +340,20 @@ async def _run_stream(session: Session, lfx: LfxClient, version, data: RunReques
def list_runs(
flow_id: UUID,
session: Annotated[Session, Depends(get_db)],
user: Annotated[User | None, Depends(get_optional_user)],
tenant_id: str | None = Query(default=None),
user: Annotated[User, Depends(get_current_user)],
):
# Authenticated callers are scoped to their session tenant unless they
# pass an explicit tenant (deprecated fallback for unauthenticated use).
if user is not None and tenant_id is None:
tenant_id = user.tenant_id
# Strict tenant scoping: the session decides, query params cannot widen.
try:
return [_read_run(r) for r in store.list_runs(session, flow_id, tenant_id)]
return [_read_run(r) for r in store.list_runs(session, flow_id, user.tenant_id)]
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@flows_router.get("/langflow/available")
async def list_langflow_flows(langflow: Annotated[LangflowClient, Depends(get_langflow)]):
async def list_langflow_flows(
langflow: Annotated[LangflowClient, Depends(get_langflow)],
_admin: Annotated[User, Depends(require_admin)],
):
"""Browse flows on the Langflow authoring server (admin import source)."""
try:
return await langflow.list_flows()
@ -344,6 +369,7 @@ async def import_flow(
session: Annotated[Session, Depends(get_db)],
langflow: Annotated[LangflowClient, Depends(get_langflow)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
_admin: Annotated[User, Depends(require_admin)],
):
"""Import a flow from Langflow: export -> version -> (deploy to LFX)."""
try:
@ -401,24 +427,38 @@ def get_input_schema(
def get_run(
run_id: UUID,
session: Annotated[Session, Depends(get_db)],
user: Annotated[User | None, Depends(get_optional_user)],
tenant_id: str | None = Query(default=None),
user: Annotated[User, Depends(get_current_user)],
):
"""Read a run. With tenant_id, cross-tenant reads 404 (v1 scoping)."""
if user is not None and tenant_id is None:
tenant_id = user.tenant_id
"""Read a run. Other tenants' runs 404 (strict session scoping)."""
try:
run = store.get_run(session, run_id)
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
if tenant_id is not None and run.tenant_id != tenant_id:
if run.tenant_id != user.tenant_id:
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
return _read_run(run)
CALLBACK_TOKEN_HEADER = "X-Callback-Token"
def _check_callback_token(token: str | None) -> None:
"""LFX-served flows prove they carry our run envelope (no session there)."""
expected = settings.callback_token
if not expected:
raise HTTPException(status_code=503, detail="flow callbacks are disabled")
if not token or not secrets.compare_digest(token, expected):
raise HTTPException(status_code=401, detail="invalid callback token")
@approvals_router.post("", response_model=ApprovalRead, status_code=201)
def create_approval(data: ApprovalCreate, session: Annotated[Session, Depends(get_db)]):
def create_approval(
data: ApprovalCreate,
session: Annotated[Session, Depends(get_db)],
callback_token: Annotated[str | None, Header(alias=CALLBACK_TOKEN_HEADER)] = None,
):
"""Flow callback contract: report a terminal approval state for a run."""
_check_callback_token(callback_token)
try:
return _read_task(store.create_approval_task(session, data))
except store.NotFoundError as exc:
@ -432,12 +472,9 @@ def create_approval(data: ApprovalCreate, session: Annotated[Session, Depends(ge
@approvals_router.get("", response_model=list[ApprovalRead])
def list_approvals(
session: Annotated[Session, Depends(get_db)],
user: Annotated[User | None, Depends(get_optional_user)],
tenant_id: str | None = Query(default=None),
user: Annotated[User, Depends(get_current_user)],
):
if user is not None and tenant_id is None:
tenant_id = user.tenant_id
return [_read_task(t) for t in store.list_approval_tasks(session, tenant_id)]
return [_read_task(t) for t in store.list_approval_tasks(session, user.tenant_id)]
@approvals_router.post("/{task_id}/decision", response_model=DecisionRead)
@ -446,8 +483,13 @@ async def decide_approval(
data: DecisionRequest,
session: Annotated[Session, Depends(get_db)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
user: Annotated[User, Depends(get_current_user)],
):
"""Decide an open task. Approve starts the continuation flow (if any)."""
"""Decide an open task. Approve starts the continuation flow (if any).
Identity comes from the session: the approver is the logged-in user,
and the continuation is the task's own (never client-chosen).
"""
try:
task = store.get_task(session, task_id)
except store.NotFoundError as exc:
@ -467,7 +509,7 @@ async def decide_approval(
session,
task,
decision="approved" if data.decision == "approve" else "rejected",
approver=data.approver,
approver=user.email,
note=data.note,
)
except store.ConflictError as exc:
@ -489,7 +531,8 @@ async def decide_approval(
payload=payload,
tenant_id=task.tenant_id,
session_id=f"continuation-of-{task.run_id}",
callback_base_url=data.callback_base_url or settings.callback_base_url,
callback_base_url=settings.callback_base_url,
callback_token=settings.callback_token,
)
except LfxNotFound as exc:
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc

View file

@ -20,6 +20,7 @@ def build_envelope(
run_id: UUID,
callback_base_url: str,
continuation_flow_id: UUID | None,
callback_token: str,
) -> str:
return json.dumps(
{
@ -27,6 +28,7 @@ def build_envelope(
"run_id": str(run_id),
"callback_url": callback_base_url.rstrip("/"),
"continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None),
"callback_token": callback_token,
}
)
@ -50,6 +52,7 @@ async def execute_run(
session_id: str | None = None,
callback_base_url: str,
continuation_flow_id: UUID | None = None,
callback_token: str = "",
) -> FlowRun:
"""Run a deployed version to a terminal state (no interrupts).
@ -68,6 +71,7 @@ async def execute_run(
run_id=run.id,
callback_base_url=callback_base_url,
continuation_flow_id=continuation_flow_id,
callback_token=callback_token,
)
try:
result = await lfx.run_flow(

View file

@ -52,16 +52,16 @@ class InputSchemaRead(BaseModel):
class RunRequest(BaseModel):
"""Start a flow run. `input` is the domain payload handed to the flow."""
"""Start a flow run. `input` is the domain payload handed to the flow.
Tenant, callback URL and continuation come from the server (session /
settings / task), never from the caller — they must not be
client-controllable (SSRF / cross-flow chaining).
"""
input: dict = Field(default_factory=dict)
version_no: int | None = None
tenant_id: str = Field(default="default", max_length=200)
session_id: str | None = None
# Base URL the flow uses for callbacks (e.g. approval tasks).
# Defaults to settings.callback_base_url; tests point it at a live server.
callback_base_url: str | None = None
continuation_flow_id: UUID | None = None
class RunRead(BaseModel):
@ -115,10 +115,10 @@ class FlowImportRead(BaseModel):
class DecisionRequest(BaseModel):
"""Decide a task. Identity comes from the session, not the body."""
decision: str = Field(pattern="^(approve|reject)$")
approver: str = Field(default="", max_length=200)
note: str = Field(default="", max_length=2000)
callback_base_url: str | None = None
class DecisionRead(BaseModel):

View file

@ -31,6 +31,11 @@ class Settings(BaseSettings):
# Base URL flows use for callbacks into this API. Must be reachable
# FROM the LFX process (compose: http://api:8000).
callback_base_url: str = "http://localhost:8000"
# Shared secret that authenticates flow callbacks (POST /api/approvals).
# The runner injects it into every run envelope; LFX-served flows send
# it back as X-Callback-Token. Empty disables callbacks (503) — set a
# long random value in prod (.env CALLBACK_TOKEN).
callback_token: str = ""
# --- access management (magic-link auth, see app/auth) ---
# Public frontend URL (magic links point at /login?token= there).

View file

@ -292,7 +292,7 @@
"list": false,
"show": true,
"multiline": true,
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n",
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n Output(\n display_name=\"Callback Token\",\n name=\"callback_token\",\n method=\"get_callback_token\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n\n def get_callback_token(self) -> Message:\n return Message(text=str(self._env().get(\"callback_token\", \"\")))\n",
"fileTypes": [],
"file_path": "",
"password": false,
@ -399,6 +399,20 @@
"allows_loop": false,
"group_outputs": false,
"tool_mode": true
},
{
"types": [
"Message"
],
"selected": "Message",
"name": "callback_token",
"display_name": "Callback Token",
"method": "get_callback_token",
"value": "__UNDEFINED__",
"cache": true,
"allows_loop": false,
"group_outputs": false,
"tool_mode": true
}
],
"field_order": [
@ -409,7 +423,7 @@
"edited": false,
"metadata": {
"module": "custom_components.envelope",
"code_hash": "2b0bd3c3d529",
"code_hash": "02e69613e210",
"dependencies": {
"total_dependencies": 1,
"dependencies": [
@ -858,6 +872,32 @@
"node": {
"template": {
"_type": "Component",
"callback_token": {
"tool_mode": false,
"trace_as_input": true,
"trace_as_metadata": true,
"load_from_db": false,
"list": false,
"list_add_label": "Add More",
"override_skip": false,
"required": false,
"placeholder": "",
"show": true,
"name": "callback_token",
"value": "",
"display_name": "Callback Token",
"advanced": false,
"api_editable": false,
"input_types": [
"Message"
],
"dynamic": false,
"info": "",
"title_case": false,
"track_in_telemetry": false,
"type": "str",
"_input_type": "MessageTextInput"
},
"callback_url": {
"tool_mode": false,
"trace_as_input": true,
@ -891,7 +931,7 @@
"list": false,
"show": true,
"multiline": true,
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n try:\n response = httpx.post(url, json=body, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"callback_token\",\n display_name=\"Callback Token\",\n ),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n headers = {\"X-Callback-Token\": self._text(self.callback_token)}\n try:\n response = httpx.post(url, json=body, headers=headers, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
"fileTypes": [],
"file_path": "",
"password": false,
@ -1040,6 +1080,7 @@
"trigger",
"run_id",
"callback_url",
"callback_token",
"continuation_flow_id",
"facts"
],
@ -1048,7 +1089,7 @@
"edited": false,
"metadata": {
"module": "custom_components.approval_callback",
"code_hash": "a334376410f7",
"code_hash": "28b2f5894566",
"dependencies": {
"total_dependencies": 2,
"dependencies": [
@ -1804,6 +1845,34 @@
"target": "CustomComponent-callback",
"targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
},
{
"animated": false,
"className": "",
"data": {
"sourceHandle": {
"dataType": "CustomComponent",
"id": "CustomComponent-envelope",
"name": "callback_token",
"output_types": [
"Message"
]
},
"targetHandle": {
"fieldName": "callback_token",
"id": "CustomComponent-callback",
"inputTypes": [
"Message"
],
"type": "str"
}
},
"id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}",
"selected": false,
"source": "CustomComponent-envelope",
"sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}",
"target": "CustomComponent-callback",
"targetHandle": "{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
},
{
"animated": false,
"className": "",

View file

@ -302,6 +302,11 @@ class EnvelopeComponent(Component):
name="continuation",
method="get_continuation",
),
Output(
display_name="Callback Token",
name="callback_token",
method="get_callback_token",
),
]
def _text(self, value) -> str:
@ -333,6 +338,9 @@ class EnvelopeComponent(Component):
def get_continuation(self) -> Message:
value = self._env().get("continuation_flow_id")
return Message(text=str(value) if value else "")
def get_callback_token(self) -> Message:
return Message(text=str(self._env().get("callback_token", "")))
"""
APPROVAL_CALLBACK_CODE = """from lfx.custom import Component
@ -348,6 +356,10 @@ class ApprovalCallbackComponent(Component):
MessageTextInput(name="trigger", display_name="Trigger"),
MessageTextInput(name="run_id", display_name="Run ID"),
MessageTextInput(name="callback_url", display_name="Callback URL"),
MessageTextInput(
name="callback_token",
display_name="Callback Token",
),
MessageTextInput(
name="continuation_flow_id",
display_name="Continuation Flow ID",
@ -384,8 +396,9 @@ class ApprovalCallbackComponent(Component):
"payload": {"verdict": trigger, "facts": facts},
"continuation_flow_id": self._text(self.continuation_flow_id) or None,
}
headers = {"X-Callback-Token": self._text(self.callback_token)}
try:
response = httpx.post(url, json=body, timeout=10.0)
response = httpx.post(url, json=body, headers=headers, timeout=10.0)
response.raise_for_status()
except Exception as exc:
return Message(text=f"CALLBACK_FAILED: {exc}")
@ -487,6 +500,7 @@ def build_approval_handoff() -> dict:
edge(router, "true_result", callback, "trigger"),
edge(envelope, "run_id", callback, "run_id"),
edge(envelope, "callback_url", callback, "callback_url"),
edge(envelope, "callback_token", callback, "callback_token"),
edge(envelope, "continuation", callback, "continuation_flow_id"),
edge(envelope, "payload", callback, "facts"),
edge(callback, "result", out_pending, "input_value"),

View file

@ -10,6 +10,8 @@ from __future__ import annotations
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
@ -18,11 +20,21 @@ import urllib.request
from pathlib import Path
BACKEND = Path(__file__).resolve().parent.parent
VENV_BIN = BACKEND / ".venv" / "bin"
# Local venv (uv run) or image venv (/opt/venv in containers): prefer PATH.
VENV_BIN = Path(shutil.which("lfx") or "").parent or (BACKEND / ".venv" / "bin")
FIXTURES = BACKEND / "tests" / "fixtures" / "flows"
LFX_PORT = 6004
API_PORT = 8000
def _free_port() -> int:
# Ephemeral ports: the dev API/LFX may already occupy the defaults
# (e.g. when smoke runs inside the api container itself).
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
LFX_PORT = _free_port()
API_PORT = _free_port()
LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs"
@ -42,6 +54,9 @@ def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | s
import httpx
with httpx.Client(base_url=f"http://127.0.0.1:{API_PORT}", timeout=120) as c:
# Management + runs are login-gated; the smoke admin session is
# planted directly in the throwaway DB (see main()).
c.cookies.set("kirche_session", "smoke-session", domain="127.0.0.1")
r = c.request(method, path, json=body)
try:
return r.status_code, r.json()
@ -68,8 +83,36 @@ def main() -> None:
cfg = Config()
cfg.set_main_option("script_location", str(BACKEND / "migrations"))
cfg.set_main_option("sqlalchemy.url", db_url)
# migrations/env.py takes the URL from settings (not the ini value):
# point this process at the throwaway DB before migrating.
from app.settings import settings as app_settings
app_settings.database_url = db_url
alembic_command.upgrade(cfg, "head")
# Plant an admin session: management + runs require login, and there is
# no SMTP in smoke to deliver a magic link.
from datetime import UTC, datetime, timedelta
from sqlmodel import Session, create_engine
from app.models import User, UserSession
engine = create_engine(db_url)
with Session(engine) as s:
user = User(name="smoke", email="smoke@example.com", role="admin")
s.add(user)
s.flush()
s.add(
UserSession(
id="smoke-session",
user_id=user.id,
expires_at=datetime.now(UTC) + timedelta(hours=1),
)
)
s.commit()
engine.dispose()
env = {
**os.environ,
# lfx prefers LANGFLOW_API_KEY over LFX_API_KEY; the repo .env may set
@ -80,6 +123,7 @@ def main() -> None:
"FLOWS_WATCH_ENABLED": "false",
"LFX_URL": f"http://127.0.0.1:{LFX_PORT}",
"CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}",
"CALLBACK_TOKEN": "smoke-callback-token",
}
lfx = subprocess.Popen(
[

View file

@ -17,6 +17,8 @@ TEST_AREA = Path(tempfile.mkdtemp(prefix="kirche-tests-"))
# Must precede all `app.*` imports: Settings is instantiated at import time.
os.environ["DATABASE_URL"] = f"sqlite:///{TEST_AREA / 'test.db'}"
os.environ["FLOWS_DIR"] = str(TEST_AREA / "flows")
# Flow callbacks (POST /api/approvals) authenticate with this token.
os.environ["CALLBACK_TOKEN"] = "test-callback-token"
# The embedded watcher + startup sync stay off in tests; sync is exercised
# directly (sync_from_directory) with per-test tmp dirs instead.
os.environ["FLOWS_WATCH_ENABLED"] = "false"
@ -81,6 +83,48 @@ async def client():
yield c
@pytest.fixture()
def login(client, session):
"""Log the test client in as a fresh user: ``await login()`` (admin)
or ``await login(role="member", tenant_id="pfarramt-a")``."""
async def _login(
email: str = "admin@example.com",
role: str = "admin",
tenant_id: str = "default",
):
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from sqlmodel import select
from app.auth.deps import SESSION_COOKIE
from app.models import User, UserSession
# Idempotent: re-login updates the existing row (tests switch
# identity mid-test by calling login() again).
user = session.exec(select(User).where(User.email == email)).first()
if user is None:
user = User(name="Test", email=email, role=role, tenant_id=tenant_id)
session.add(user)
else:
user.role = role
user.tenant_id = tenant_id
session.add(user)
session.flush()
user_session = UserSession(
id=f"test-session-{uuid4().hex}",
user_id=user.id,
expires_at=datetime.now(UTC) + timedelta(days=1),
)
session.add(user_session)
session.commit()
client.cookies.set(SESSION_COOKIE, user_session.id)
return user
return _login
@pytest.fixture(scope="session")
def lfx_server(tmp_path_factory):
"""Real `lfx serve` subprocess on a free port; points settings at it.

View file

@ -292,7 +292,7 @@
"list": false,
"show": true,
"multiline": true,
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n",
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n Output(\n display_name=\"Callback Token\",\n name=\"callback_token\",\n method=\"get_callback_token\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n\n def get_callback_token(self) -> Message:\n return Message(text=str(self._env().get(\"callback_token\", \"\")))\n",
"fileTypes": [],
"file_path": "",
"password": false,
@ -399,6 +399,20 @@
"allows_loop": false,
"group_outputs": false,
"tool_mode": true
},
{
"types": [
"Message"
],
"selected": "Message",
"name": "callback_token",
"display_name": "Callback Token",
"method": "get_callback_token",
"value": "__UNDEFINED__",
"cache": true,
"allows_loop": false,
"group_outputs": false,
"tool_mode": true
}
],
"field_order": [
@ -409,7 +423,7 @@
"edited": false,
"metadata": {
"module": "custom_components.envelope",
"code_hash": "2b0bd3c3d529",
"code_hash": "02e69613e210",
"dependencies": {
"total_dependencies": 1,
"dependencies": [
@ -858,6 +872,32 @@
"node": {
"template": {
"_type": "Component",
"callback_token": {
"tool_mode": false,
"trace_as_input": true,
"trace_as_metadata": true,
"load_from_db": false,
"list": false,
"list_add_label": "Add More",
"override_skip": false,
"required": false,
"placeholder": "",
"show": true,
"name": "callback_token",
"value": "",
"display_name": "Callback Token",
"advanced": false,
"api_editable": false,
"input_types": [
"Message"
],
"dynamic": false,
"info": "",
"title_case": false,
"track_in_telemetry": false,
"type": "str",
"_input_type": "MessageTextInput"
},
"callback_url": {
"tool_mode": false,
"trace_as_input": true,
@ -891,7 +931,7 @@
"list": false,
"show": true,
"multiline": true,
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n try:\n response = httpx.post(url, json=body, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"callback_token\",\n display_name=\"Callback Token\",\n ),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n headers = {\"X-Callback-Token\": self._text(self.callback_token)}\n try:\n response = httpx.post(url, json=body, headers=headers, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
"fileTypes": [],
"file_path": "",
"password": false,
@ -1040,6 +1080,7 @@
"trigger",
"run_id",
"callback_url",
"callback_token",
"continuation_flow_id",
"facts"
],
@ -1048,7 +1089,7 @@
"edited": false,
"metadata": {
"module": "custom_components.approval_callback",
"code_hash": "a334376410f7",
"code_hash": "28b2f5894566",
"dependencies": {
"total_dependencies": 2,
"dependencies": [
@ -1804,6 +1845,34 @@
"target": "CustomComponent-callback",
"targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
},
{
"animated": false,
"className": "",
"data": {
"sourceHandle": {
"dataType": "CustomComponent",
"id": "CustomComponent-envelope",
"name": "callback_token",
"output_types": [
"Message"
]
},
"targetHandle": {
"fieldName": "callback_token",
"id": "CustomComponent-callback",
"inputTypes": [
"Message"
],
"type": "str"
}
},
"id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}",
"selected": false,
"source": "CustomComponent-envelope",
"sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}",
"target": "CustomComponent-callback",
"targetHandle": "{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
},
{
"animated": false,
"className": "",

View file

@ -30,16 +30,14 @@ async def _version_and_deploy(client, flow_id, fixture="simple_rule.json"):
return response.json()
async def _handoff_task(client, live_server, continuation_flow_id=None):
async def _handoff_task(client, live_server, monkeypatch):
"""Run approval_handoff over threshold; returns (run, task)."""
from app.settings import settings
monkeypatch.setattr(settings, "callback_base_url", live_server)
flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}")
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
body = {
"input": {"betrag": 250000, "schwelle": 200000},
"callback_base_url": live_server,
}
if continuation_flow_id is not None:
body["continuation_flow_id"] = continuation_flow_id
body = {"input": {"betrag": 250000, "schwelle": 200000}}
run = (await client.post(f"/api/flows/{flow['id']}/run", json=body)).json()
assert run["status"] == "pending_approval"
tasks = (await client.get("/api/approvals")).json()
@ -47,30 +45,55 @@ async def _handoff_task(client, live_server, continuation_flow_id=None):
return run, task
async def test_approve_without_continuation(client, lfx_server, live_server):
_, task = await _handoff_task(client, live_server)
async def test_approve_without_continuation(client, lfx_server, live_server, login, monkeypatch):
await login()
_, task = await _handoff_task(client, live_server, monkeypatch)
response = await client.post(
f"/api/approvals/{task['id']}/decision",
json={"decision": "approve", "approver": "sachbearbeiter", "note": "ok"},
json={"decision": "approve", "note": "ok"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["task"]["status"] == "approved"
assert body["task"]["approver"] == "sachbearbeiter"
assert body["task"]["approver"] == "admin@example.com"
assert body["task"]["decided_at"] is not None
assert body["continuation_run"] is None
async def test_approve_with_continuation_runs_facts(client, lfx_server, live_server):
async def test_approve_with_continuation_runs_facts(
client, lfx_server, live_server, login, monkeypatch
):
await login()
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
_, task = await _handoff_task(client, live_server, continuation_flow_id=cont["id"])
# Continuation is authoring-time wiring on the definition (admin-only);
# run bodies cannot set it.
response = await client.post(
"/api/flows",
json={"name": f"handoff-{uuid4().hex[:8]}", "continuation_flow_id": cont["id"]},
)
assert response.status_code == 201, response.text
flow = response.json()
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
from app.settings import settings
monkeypatch.setattr(settings, "callback_base_url", live_server)
run = (
await client.post(
f"/api/flows/{flow['id']}/run",
json={"input": {"betrag": 250000, "schwelle": 200000}},
)
).json()
assert run["status"] == "pending_approval"
tasks = (await client.get("/api/approvals")).json()
task = next(t for t in tasks if t["run_id"] == run["id"])
assert task["continuation_flow_id"] == cont["id"]
response = await client.post(
f"/api/approvals/{task['id']}/decision",
json={"decision": "approve", "approver": "chef"},
json={"decision": "approve"},
)
assert response.status_code == 200, response.text
body = response.json()
@ -83,21 +106,51 @@ async def test_approve_with_continuation_runs_facts(client, lfx_server, live_ser
assert "Sonderfall" in cont_run["output"]["result"]
async def test_reject_task(client, lfx_server, live_server):
_, task = await _handoff_task(client, live_server)
async def test_client_continuation_is_ignored(client, lfx_server, live_server, login, monkeypatch):
"""A run body cannot chain arbitrary flows (G)."""
from app.settings import settings
await login()
monkeypatch.setattr(settings, "callback_base_url", live_server)
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}")
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
run = (
await client.post(
f"/api/flows/{flow['id']}/run",
json={
"input": {"betrag": 250000, "schwelle": 200000},
"continuation_flow_id": cont["id"],
},
)
).json()
assert run["status"] == "pending_approval"
tasks = (await client.get("/api/approvals")).json()
task = next(t for t in tasks if t["run_id"] == run["id"])
assert task["continuation_flow_id"] is None
async def test_reject_task(client, lfx_server, live_server, login, monkeypatch):
await login()
_, task = await _handoff_task(client, live_server, monkeypatch)
# Any logged-in user (not just admins) can decide; identity is the session.
await login(email="chef@example.com", role="member")
response = await client.post(
f"/api/approvals/{task['id']}/decision",
json={"decision": "reject", "approver": "chef", "note": "zu teuer"},
json={"decision": "reject", "note": "zu teuer"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["task"]["status"] == "rejected"
assert body["task"]["approver"] == "chef@example.com"
assert body["task"]["decision_note"] == "zu teuer"
assert body["continuation_run"] is None
async def test_decision_guards(client, lfx_server, live_server):
_, task = await _handoff_task(client, live_server)
async def test_decision_guards(client, lfx_server, live_server, login, monkeypatch):
await login()
_, task = await _handoff_task(client, live_server, monkeypatch)
url = f"/api/approvals/{task['id']}/decision"
response = await client.post(url, json={"decision": "approve"})
@ -108,7 +161,7 @@ async def test_decision_guards(client, lfx_server, live_server):
response = await client.post(f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"})
assert response.status_code == 404
_, task2 = await _handoff_task(client, live_server)
_, task2 = await _handoff_task(client, live_server, monkeypatch)
response = await client.post(
f"/api/approvals/{task2['id']}/decision", json={"decision": "maybe"}
)
@ -122,9 +175,10 @@ async def test_decision_guards(client, lfx_server, live_server):
assert response.status_code == 200
async def test_decision_bad_continuation_reference(client, session):
async def test_decision_bad_continuation_reference(client, session, login):
from app.flows import store as flow_store
await login()
flow = await _create_flow(client, f"badcont-{uuid4().hex[:8]}")
version = (
await client.post(
@ -147,39 +201,45 @@ async def test_decision_bad_continuation_reference(client, session):
assert response.status_code == 422
async def test_tenant_isolation(client, lfx_server):
async def test_tenant_isolation(client, lfx_server, login):
"""Strict session scoping: explicit ?tenant_id= cannot widen reads (E)."""
await login()
flow = await _create_flow(client, f"tenant-{uuid4().hex[:8]}")
await _version_and_deploy(client, flow["id"])
for tenant in ("pfarramt-a", "pfarramt-b"):
response = await client.post(
f"/api/flows/{flow['id']}/run",
json={"input": {"betrag": 1}, "tenant_id": tenant},
)
assert response.status_code == 200
await login(email="a@example.com", role="member", tenant_id="pfarramt-a")
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
assert response.status_code == 200
run_a = response.json()
assert run_a["tenant_id"] == "pfarramt-a"
await login(email="b@example.com", role="member", tenant_id="pfarramt-b")
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
assert response.status_code == 200
# A forged tenant_id query param is ignored: b sees only b's runs.
response = await client.get(f"/api/flows/{flow['id']}/runs", params={"tenant_id": "pfarramt-a"})
runs_a = response.json()
assert len(runs_a) == 1
assert runs_a[0]["tenant_id"] == "pfarramt-a"
runs_b = response.json()
assert len(runs_b) == 1
assert runs_b[0]["tenant_id"] == "pfarramt-b"
response = await client.get(f"/api/flows/{flow['id']}/runs")
assert len(response.json()) == 2
# Cross-tenant read with scoping 404s.
response = await client.get(
f"/api/flows/runs/{runs_a[0]['id']}", params={"tenant_id": "pfarramt-b"}
)
# Cross-tenant run read 404s with or without the forged param.
response = await client.get(f"/api/flows/runs/{run_a['id']}")
assert response.status_code == 404
response = await client.get(
f"/api/flows/runs/{runs_a[0]['id']}", params={"tenant_id": "pfarramt-a"}
f"/api/flows/runs/{run_a['id']}", params={"tenant_id": "pfarramt-a"}
)
assert response.status_code == 404
await login(email="a@example.com", role="member", tenant_id="pfarramt-a")
response = await client.get(f"/api/flows/runs/{run_a['id']}")
assert response.status_code == 200
async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch):
async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch, login):
from app.settings import settings
await login()
flow = await _create_flow(client, f"down-{uuid4().hex[:8]}")
await client.post(
f"/api/flows/{flow['id']}/versions",
@ -198,10 +258,11 @@ async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch):
assert response.status_code == 503
async def test_lfx_drift_maps_to_502(client, lfx_server):
async def test_lfx_drift_maps_to_502(client, lfx_server, login):
from app.flows.lfx_client import LfxClient
from app.settings import settings
await login()
flow = await _create_flow(client, f"drift-{uuid4().hex[:8]}")
deployed = await _version_and_deploy(client, flow["id"])
await LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None).delete_flow(
@ -211,7 +272,8 @@ async def test_lfx_drift_maps_to_502(client, lfx_server):
assert response.status_code == 502
async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server):
async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server, login):
await login()
flow = await _create_flow(client, f"corrupt-{uuid4().hex[:8]}")
bogus = {
"name": "bogus",

View file

@ -24,8 +24,11 @@ def _export_doc(name="importiert"):
}
async def test_import_creates_definition_version_and_deploys(client, lfx_server, override_langflow):
async def test_import_creates_definition_version_and_deploys(
client, login, lfx_server, override_langflow
):
override_langflow(_export_doc())
await login()
response = await client.post(
"/api/flows/import",
json={"langflow_flow_id": LANGFLOW_ID, "author": "admin"},
@ -46,8 +49,9 @@ async def test_import_creates_definition_version_and_deploys(client, lfx_server,
assert response.json()["status"] == "completed"
async def test_import_same_content_is_idempotent(client, lfx_server, override_langflow):
async def test_import_same_content_is_idempotent(client, login, lfx_server, override_langflow):
override_langflow(_export_doc())
await login()
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
second = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert first.status_code == 201
@ -57,8 +61,9 @@ async def test_import_same_content_is_idempotent(client, lfx_server, override_la
assert second.json()["version"]["version_no"] == 1
async def test_import_changed_content_bumps_version(client, lfx_server, override_langflow):
async def test_import_changed_content_bumps_version(client, login, lfx_server, override_langflow):
override_langflow(_export_doc())
await login()
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert first.status_code == 201
changed = _export_doc()
@ -70,12 +75,13 @@ async def test_import_changed_content_bumps_version(client, lfx_server, override
async def test_import_writes_canonical_file(
client, lfx_server, override_langflow, tmp_path, monkeypatch
client, login, lfx_server, override_langflow, tmp_path, monkeypatch
):
from app.settings import settings
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
override_langflow(_export_doc())
await login()
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert response.status_code == 201, response.text
written = tmp_path / "importiert.json"
@ -83,8 +89,9 @@ async def test_import_writes_canonical_file(
assert json.loads(written.read_text())["name"] == "importiert"
async def test_import_with_explicit_name(client, lfx_server, override_langflow):
async def test_import_with_explicit_name(client, login, lfx_server, override_langflow):
override_langflow(_export_doc(name="langflow-name"))
await login()
response = await client.post(
"/api/flows/import",
json={"langflow_flow_id": LANGFLOW_ID, "name": "wunschname"},
@ -93,8 +100,9 @@ async def test_import_with_explicit_name(client, lfx_server, override_langflow):
assert response.json()["definition"]["name"] == "wunschname"
async def test_import_without_deploy(client, override_langflow):
async def test_import_without_deploy(client, login, override_langflow):
override_langflow(_export_doc())
await login()
response = await client.post(
"/api/flows/import",
json={"langflow_flow_id": LANGFLOW_ID, "deploy": False},
@ -105,20 +113,23 @@ async def test_import_without_deploy(client, override_langflow):
assert body["version"]["deployed_at"] is None
async def test_import_langflow_not_found(client, override_langflow):
async def test_import_langflow_not_found(client, login, override_langflow):
override_langflow(status=404)
await login()
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert response.status_code == 404
async def test_import_langflow_unreachable(client, override_langflow):
async def test_import_langflow_unreachable(client, login, override_langflow):
override_langflow(status=0)
await login()
response = await client.post("/api/flows/import", json={"langflow_flow_id": str(uuid4())})
assert response.status_code == 503
async def test_list_langflow_flows(client, override_langflow):
async def test_list_langflow_flows(client, login, override_langflow):
override_langflow(_export_doc())
await login()
response = await client.get("/api/flows/langflow/available")
assert response.status_code == 200
assert response.json() == [{"id": "fake-1", "name": "fake"}]

View file

@ -7,6 +7,7 @@ from uuid import UUID, uuid4
from app.flows.sync import lfx_id_for
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
CALLBACK_HEADERS = {"X-Callback-Token": "test-callback-token"}
def load_fixture(name: str) -> dict:
@ -34,7 +35,8 @@ async def _deploy(client, flow_id, version_no=1):
return response.json()
async def test_flow_crud(client):
async def test_flow_crud(client, login):
await login()
created = await _create_flow(client, "crud-flow")
assert created["status"] == "active"
@ -53,7 +55,63 @@ async def test_flow_crud(client):
assert response.status_code == 404
async def test_version_validation_and_numbering(client):
async def test_management_endpoints_require_admin(client, login):
"""Anonymous -> 401, member -> 403, admin -> through (A)."""
flow_id = uuid4()
probes = [
("POST", "/api/flows", {"name": "x"}),
("POST", "/api/flows/sync", None),
("POST", f"/api/flows/{flow_id}/versions", {"langflow_json": {}}),
("GET", f"/api/flows/{flow_id}/versions", None),
("POST", f"/api/flows/{flow_id}/versions/1/deploy", None),
("POST", "/api/flows/import", {"langflow_flow_id": "x"}),
("GET", "/api/flows/langflow/available", None),
]
for method, path, body in probes:
if method == "POST":
response = await client.post(path, json=body or {})
else:
response = await client.get(path)
assert response.status_code == 401, (method, path, response.text)
await login(email="member@example.com", role="member")
for method, path, body in probes:
if method == "POST":
response = await client.post(path, json=body or {})
else:
response = await client.get(path)
assert response.status_code == 403, (method, path, response.text)
async def test_runs_and_reads_require_login(client, login):
"""Run, run reads and approval reads are login-gated (A)."""
await login()
flow = await _create_flow(client, "gated")
run_id = uuid4()
authed_paths = [
("POST", f"/api/flows/{flow['id']}/run", {"input": {}}),
("GET", f"/api/flows/{flow['id']}/runs", None),
("GET", f"/api/flows/runs/{run_id}", None),
("GET", "/api/approvals", None),
("POST", f"/api/approvals/{uuid4()}/decision", {"decision": "approve"}),
]
# Fresh client without a session cookie.
from httpx import ASGITransport, AsyncClient
from app.main import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as anon:
for method, path, body in authed_paths:
if method == "POST":
response = await anon.post(path, json=body or {})
else:
response = await anon.get(path)
assert response.status_code == 401, (method, path, response.text)
async def test_version_validation_and_numbering(client, login):
await login()
flow = await _create_flow(client, "versioned")
v1 = await _create_version(client, flow["id"])
@ -77,7 +135,8 @@ async def test_version_validation_and_numbering(client):
assert [v["version_no"] for v in response.json()] == [1, 2]
async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server):
async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server, login):
await login()
flow = await _create_flow(client, "dachsanierung")
await _create_version(client, flow["id"])
deployed = await _deploy(client, flow["id"])
@ -108,14 +167,16 @@ async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server):
assert response.json()["id"] == run["id"]
async def test_run_undeployed_version_fails(client):
async def test_run_undeployed_version_fails(client, login):
await login()
flow = await _create_flow(client, "undeployed")
await _create_version(client, flow["id"])
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
assert response.status_code == 422
async def test_run_explicit_version_no(client, lfx_server):
async def test_run_explicit_version_no(client, lfx_server, login):
await login()
flow = await _create_flow(client, "explicit-version")
await _create_version(client, flow["id"])
await _create_version(client, flow["id"])
@ -128,7 +189,8 @@ async def test_run_explicit_version_no(client, lfx_server):
assert response.json()["status"] == "completed"
async def test_run_stream_events(client, lfx_server):
async def test_run_stream_events(client, lfx_server, login):
await login()
flow = await _create_flow(client, "streamed")
await _create_version(client, flow["id"])
await _deploy(client, flow["id"])
@ -161,17 +223,18 @@ async def test_run_stream_events(client, lfx_server):
assert response.json()["status"] == "completed"
async def test_approval_handoff_end_to_end(client, lfx_server, live_server):
async def test_approval_handoff_end_to_end(client, lfx_server, live_server, login, monkeypatch):
from app.settings import settings
await login()
monkeypatch.setattr(settings, "callback_base_url", live_server)
flow = await _create_flow(client, "freigabe")
await _create_version(client, flow["id"], fixture="approval_handoff.json")
await _deploy(client, flow["id"])
response = await client.post(
f"/api/flows/{flow['id']}/run",
json={
"input": {"betrag": 250000, "schwelle": 200000},
"callback_base_url": live_server,
},
json={"input": {"betrag": 250000, "schwelle": 200000}},
)
assert response.status_code == 200, response.text
run = response.json()
@ -188,17 +251,20 @@ async def test_approval_handoff_end_to_end(client, lfx_server, live_server):
assert task["payload"]["facts"]["betrag"] == 250000
async def test_approval_handoff_auto_creates_no_task(client, lfx_server, live_server):
async def test_approval_handoff_auto_creates_no_task(
client, lfx_server, live_server, login, monkeypatch
):
from app.settings import settings
await login()
monkeypatch.setattr(settings, "callback_base_url", live_server)
flow = await _create_flow(client, "freigabe-auto")
await _create_version(client, flow["id"], fixture="approval_handoff.json")
await _deploy(client, flow["id"])
response = await client.post(
f"/api/flows/{flow['id']}/run",
json={
"input": {"betrag": 50000, "schwelle": 200000},
"callback_base_url": live_server,
},
json={"input": {"betrag": 50000, "schwelle": 200000}},
)
assert response.status_code == 200, response.text
assert response.json()["status"] == "completed"
@ -208,11 +274,12 @@ async def test_approval_handoff_auto_creates_no_task(client, lfx_server, live_se
assert response.json() == []
async def test_approval_callback_contract(client, session):
async def test_approval_callback_contract(client, session, login):
from uuid import UUID
from app.flows import store as flow_store
await login()
flow = await _create_flow(client, "callback-target")
version = await _create_version(client, flow["id"])
version_obj = flow_store.get_version(session, UUID(version["id"]))
@ -222,6 +289,7 @@ async def test_approval_callback_contract(client, session):
response = await client.post(
"/api/approvals",
json={"run_id": str(run.id), "title": "bitte pruefen", "payload": {"x": 1}},
headers=CALLBACK_HEADERS,
)
assert response.status_code == 201, response.text
task = response.json()
@ -229,18 +297,46 @@ async def test_approval_callback_contract(client, session):
assert task["tenant_id"] == "default"
assert task["version_id"] == version["id"]
response = await client.get(f"/api/flows/runs/{run.id}")
response = await client.get(f"/api/flows/runs/{run.id}", headers=CALLBACK_HEADERS)
assert response.json()["status"] == "pending_approval"
response = await client.post("/api/approvals", json={"run_id": str(run.id)})
response = await client.post(
"/api/approvals", json={"run_id": str(run.id)}, headers=CALLBACK_HEADERS
)
assert response.status_code == 409
run2 = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
response = await client.post(
"/api/approvals",
json={"run_id": str(run2.id), "continuation_flow_id": str(uuid4())},
headers=CALLBACK_HEADERS,
)
assert response.status_code == 422
response = await client.post("/api/approvals", json={"run_id": str(uuid4())})
response = await client.post(
"/api/approvals", json={"run_id": str(uuid4())}, headers=CALLBACK_HEADERS
)
assert response.status_code == 404
async def test_approval_callback_token_required(client, session, login):
"""Forged callbacks without the shared token are rejected (C)."""
from uuid import UUID
from app.flows import store as flow_store
await login()
flow = await _create_flow(client, "callback-gated")
version = await _create_version(client, flow["id"])
version_obj = flow_store.get_version(session, UUID(version["id"]))
run = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
response = await client.post("/api/approvals", json={"run_id": str(run.id)})
assert response.status_code == 401
response = await client.post(
"/api/approvals",
json={"run_id": str(run.id)},
headers={"X-Callback-Token": "wrong"},
)
assert response.status_code == 401

View file

@ -77,7 +77,8 @@ def test_intake_fixture_shape():
assert ("CustomInputForm-intake", "ChatOutput-collected") in edges
async def test_intake_deploy_and_run_with_values(client, lfx_server):
async def test_intake_deploy_and_run_with_values(client, lfx_server, login):
await login()
doc = json.loads((FIXTURES / "intake_form.json").read_text())
created = await client.post("/api/flows", json={"name": "intake-e2e"})
assert created.status_code == 201, created.text

View file

@ -170,9 +170,10 @@ async def test_sync_caches_schema_per_version(session, lfx_server, tmp_path):
assert "form_values" not in cached["json_schema"]["properties"]
async def test_input_schema_endpoint(client, lfx_server):
async def test_input_schema_endpoint(client, lfx_server, login):
assert (await client.get(f"/api/flows/{uuid4()}/input-schema")).status_code == 404
await login()
doc = json.loads((FIXTURES / "intake_form.json").read_text())
created = await client.post("/api/flows", json={"name": "schema-e2e"})
flow_id = created.json()["id"]

View file

@ -118,7 +118,8 @@ async def test_sync_rename_updates_definition_name(session, lfx_server, tmp_path
assert definition.name == "umbenannt"
async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkeypatch):
async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkeypatch, login):
await login()
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
_seed_dir(tmp_path)
response = await client.post("/api/flows/sync")