feat: approval decisions, tenant scoping, error paths with tests

- POST /api/approvals/{id}/decision (approve starts continuation flow
  with task facts; reject closes; 404/409/422 guards)
- Tenant scoping: list filters + cross-tenant reads 404
- Error mapping covered: LFX down 503, LFX drift 502, corrupt flow 422
- 34 tests green
This commit is contained in:
Jonas Miederer 2026-09-06 23:06:34 +00:00
parent 1034206870
commit 6c195bc2e7
4 changed files with 389 additions and 12 deletions

View file

@ -34,6 +34,8 @@ from .lfx_client import (
from .schemas import (
ApprovalCreate,
ApprovalRead,
DecisionRead,
DecisionRequest,
FlowDefinitionCreate,
FlowDefinitionRead,
FlowImportRead,
@ -290,9 +292,13 @@ async def _run_stream(session: Session, lfx: LfxClient, version, data: RunReques
@flows_router.get("/{flow_id}/runs", response_model=list[RunRead])
def list_runs(flow_id: UUID, session: Session = Depends(get_db)):
def list_runs(
flow_id: UUID,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_db),
):
try:
return [_read_run(r) for r in store.list_runs(session, flow_id)]
return [_read_run(r) for r in store.list_runs(session, flow_id, tenant_id)]
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@ -350,11 +356,19 @@ async def import_flow(
@flows_router.get("/runs/{run_id}", response_model=RunRead)
def get_run(run_id: UUID, session: Session = Depends(get_db)):
def get_run(
run_id: UUID,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_db),
):
"""Read a run. With tenant_id, cross-tenant reads 404 (v1 scoping)."""
try:
return _read_run(store.get_run(session, run_id))
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:
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
return _read_run(run)
@approvals_router.post("", response_model=ApprovalRead, status_code=201)
@ -376,3 +390,66 @@ def list_approvals(
session: Session = Depends(get_db),
):
return [_read_task(t) for t in store.list_approval_tasks(session, tenant_id)]
@approvals_router.post("/{task_id}/decision", response_model=DecisionRead)
async def decide_approval(
task_id: UUID,
data: DecisionRequest,
session: Session = Depends(get_db),
lfx: LfxClient = Depends(get_lfx),
):
"""Decide an open task. Approve starts the continuation flow (if any)."""
try:
task = store.get_task(session, task_id)
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
# Resolve the continuation first: bad references fail before any state change.
continuation_version = None
if data.decision == "approve" and task.continuation_flow_id is not None:
try:
continuation_version = store.get_latest_deployed_version(
session, task.continuation_flow_id
)
except (store.NotFoundError, store.InvalidError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
try:
task = store.decide_task(
session,
task,
decision="approved" if data.decision == "approve" else "rejected",
approver=data.approver,
note=data.note,
)
except store.ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
# Note: the decision is committed before the continuation runs. If LFX is
# down, the task stays decided and the continuation can be started
# manually from the preserved task payload.
continuation_run = None
if continuation_version is not None:
payload = task.payload
if isinstance(payload.get("facts"), dict):
payload = payload["facts"]
try:
continuation_run = await runner.execute_run(
session,
lfx,
continuation_version,
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,
)
except LfxNotFound as exc:
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
except LfxUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except LfxError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return DecisionRead(
task=_read_task(task),
continuation_run=_read_run(continuation_run) if continuation_run else None,
)