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,
)

View file

@ -98,3 +98,15 @@ class FlowImportRequest(BaseModel):
class FlowImportRead(BaseModel):
definition: FlowDefinitionRead
version: FlowVersionRead
class DecisionRequest(BaseModel):
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):
task: ApprovalRead
continuation_run: RunRead | None

View file

@ -174,16 +174,19 @@ def get_run(session: Session, run_id: UUID) -> FlowRun:
return run
def list_runs(session: Session, flow_id: UUID) -> list[FlowRun]:
def list_runs(
session: Session, flow_id: UUID, tenant_id: str | None = None
) -> list[FlowRun]:
get_definition(session, flow_id)
return list(
session.exec(
select(FlowRun)
.join(FlowVersion, FlowVersion.id == FlowRun.version_id)
.where(FlowVersion.flow_id == flow_id)
.order_by(FlowRun.created_at.desc())
).all()
query = (
select(FlowRun)
.join(FlowVersion, FlowVersion.id == FlowRun.version_id)
.where(FlowVersion.flow_id == flow_id)
.order_by(FlowRun.created_at.desc())
)
if tenant_id is not None:
query = query.where(FlowRun.tenant_id == tenant_id)
return list(session.exec(query).all())
def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask:
@ -223,3 +226,33 @@ def list_approval_tasks(session: Session, tenant_id: str | None = None) -> list[
if tenant_id is not None:
query = query.where(ApprovalTask.tenant_id == tenant_id)
return list(session.exec(query).all())
def get_task(session: Session, task_id: UUID) -> ApprovalTask:
task = session.get(ApprovalTask, task_id)
if task is None:
raise NotFoundError(f"approval task {task_id} not found")
return task
def decide_task(
session: Session,
task: ApprovalTask,
*,
decision: str,
approver: str = "",
note: str = "",
) -> ApprovalTask:
"""Approve or reject an open task. Decided tasks are immutable (409)."""
if task.status != "open":
raise ConflictError(f"task {task.id} is already {task.status}")
if decision not in ("approved", "rejected"):
raise InvalidError(f"unknown decision {decision!r}")
task.status = decision
task.approver = approver
task.decision_note = note
task.decided_at = datetime.now(timezone.utc)
session.add(task)
session.commit()
session.refresh(task)
return task