fix(workflows): F12 (Astra P1) — approve/reject an zentralen Approval-Vertrag anpassen

Vorher: Beide Routen behandelten die Rueckgabe von create_approval_request
als Dictionary (approval["id"] -> TypeError: ApprovalRequest object is
not subscriptable, Astra-Repro), riefen resolve_approval_request mit
nicht existierendem decided_by statt approver_id und ohne tenant_id auf
— und erzeugten bei JEDEM Aufruf eine NEUE Anfrage, die sie sofort
selbst genehmigten, statt die wartende Engine-Anfrage aufzuloesen.

Fix (beide Routen, approve + reject):
- Suchen die BESTEHENDE pending ApprovalRequest der Engine
  (entity_type=workflow_instance, entity_id, status=pending, neueste
  zuerst) und loesen genau diese auf — keine Selbst-Genehmigung mehr
- Korrekte F11-Signatur: (db, tenant_id, request_id, decision=,
  approver_id=, comment=, is_system_admin=) + ApprovalDecisionError-
  Behandlung (403/409/410) Keine wartende Anfrage -> 409 no_pending_approval
  (kla rer Zustand statt stiller Neubau)
- advance_instance/cancel_instance laufen wie gehabt NACH erfolgreicher
  Aufloesung

Abnahme (Astra): Beide URLs funktionieren; Zustandswechsel, Audit und
Freigabe stimmen; Wiederholung erzeugt keinen zweiten Fortschritt —
erfuellt (resolve wirft 409 not_pending bei Zweitentscheid).

Tests: test_phase_g_workflows + test_s1_security_guards 60/60. ruff clean.
This commit is contained in:
Agent Zero
2026-09-18 11:08:49 +02:00
parent d3142e07cb
commit 5169b12795
+96 -34
View File
@@ -464,7 +464,14 @@ async def approve_workflow_step(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("workflows:write")),
):
"""Approve the current approval step of a workflow instance."""
"""Approve the current approval step of a workflow instance.
F12 (Astra P1): resolves the EXISTING pending approval request that
the workflow engine created when the decision guard paused the flow —
instead of creating a NEW request and self-approving it (old behaviour:
TypeError on approval["id"] — create returns an ORM object, and the
call used a nonexistent ``decided_by`` kwarg).
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
@@ -476,7 +483,11 @@ async def approve_workflow_step(
from sqlalchemy import select
from app.core.approval import create_approval_request, resolve_approval_request
from app.core.approval import (
ApprovalDecisionError,
ApprovalRequest,
resolve_approval_request,
)
from app.models.workflow import WorkflowInstance
result = await db.execute(
@@ -493,22 +504,44 @@ async def approve_workflow_step(
detail={"detail": "Instance not found", "code": "not_found"},
)
approval = await create_approval_request(
db=db,
tenant_id=tenant_id,
entity_type="workflow_instance",
entity_id=uuid.UUID(instance_id),
action="workflow_step_approval",
requested_by=user_id,
requested_by_type="user",
)
await resolve_approval_request(
db=db,
request_id=approval["id"],
decision="approved",
decided_by=user_id,
comment=comment,
# F12: find the EXISTING pending approval the engine created for this
# instance — do not create a new, instantly self-approved one.
pending_result = await db.execute(
select(ApprovalRequest)
.where(
ApprovalRequest.tenant_id == tenant_id,
ApprovalRequest.entity_type == "workflow_instance",
ApprovalRequest.entity_id == instance.id,
ApprovalRequest.status == "pending",
)
.order_by(ApprovalRequest.created_at.desc())
.limit(1)
)
pending = pending_result.scalar_one_or_none()
if pending is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"detail": "No pending approval request for this workflow instance",
"code": "no_pending_approval",
},
)
try:
await resolve_approval_request(
db,
tenant_id,
pending.id,
decision="approved",
approver_id=user_id,
comment=comment,
is_system_admin=bool(current_user.get("is_system_admin", False)),
)
except ApprovalDecisionError as exc:
raise HTTPException(
status_code=exc.http_status,
detail={"detail": str(exc), "code": exc.code},
) from exc
return await workflow_service.advance_instance(
db,
@@ -528,7 +561,11 @@ async def reject_workflow_step(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("workflows:write")),
):
"""Reject the current approval step of a workflow instance."""
"""Reject the current approval step of a workflow instance.
F12 (Astra P1): resolves the EXISTING pending approval request that
the workflow engine created — same contract fix as approve.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
@@ -540,7 +577,11 @@ async def reject_workflow_step(
from sqlalchemy import select
from app.core.approval import create_approval_request, resolve_approval_request
from app.core.approval import (
ApprovalDecisionError,
ApprovalRequest,
resolve_approval_request,
)
from app.models.workflow import WorkflowInstance
result = await db.execute(
@@ -557,22 +598,43 @@ async def reject_workflow_step(
detail={"detail": "Instance not found", "code": "not_found"},
)
approval = await create_approval_request(
db=db,
tenant_id=tenant_id,
entity_type="workflow_instance",
entity_id=uuid.UUID(instance_id),
action="workflow_step_approval",
requested_by=user_id,
requested_by_type="user",
)
await resolve_approval_request(
db=db,
request_id=approval["id"],
decision="rejected",
decided_by=user_id,
comment=comment,
# F12: resolve the EXISTING pending approval for this instance.
pending_result = await db.execute(
select(ApprovalRequest)
.where(
ApprovalRequest.tenant_id == tenant_id,
ApprovalRequest.entity_type == "workflow_instance",
ApprovalRequest.entity_id == instance.id,
ApprovalRequest.status == "pending",
)
.order_by(ApprovalRequest.created_at.desc())
.limit(1)
)
pending = pending_result.scalar_one_or_none()
if pending is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"detail": "No pending approval request for this workflow instance",
"code": "no_pending_approval",
},
)
try:
await resolve_approval_request(
db,
tenant_id,
pending.id,
decision="rejected",
approver_id=user_id,
comment=comment,
is_system_admin=bool(current_user.get("is_system_admin", False)),
)
except ApprovalDecisionError as exc:
raise HTTPException(
status_code=exc.http_status,
detail={"detail": str(exc), "code": exc.code},
) from exc
return await workflow_service.cancel_instance(
db,