Files
leocrm/app/routes/approvals.py
T
Agent Zero 015b7e32f3 fix(security): F11 (Astra P1) — Freigaben an Entscheider, Ablauf und Atomizitaet binden
Vorher: resolve_approval_request pruefte nur Mandant + pending — NICHT
Ablaufdatum, NICHT den vorgesehenen Genehmiger, und ueberschrieb
approver_id mit dem tatsaechlichen Entscheider (Zuordnung verloren).
Astra-Repro: Eine abgelaufene Anfrage konnte von einem anderen
Entscheider genehmigt werden; konkurrierende Entscheidungen waren
moeglich.

Fix:
- Migration 0146: neue Spalte resolved_by (Zuordnung vs. Entscheider
  getrennt — approver_id bleibt die ZUORDNUNG)
- resolve_approval_request komplett ueberarbeitet:
  * Ablauf-Check: expires_at vorbei -> Status expired + 410
  * Genehmiger-Check: approver_id match ODER approver_group-Mitgliedschaft;
    unassigned = jeder mit approvals:approve; System-Admin als
    dokumentierter Ops-Override; falscher Entscheider -> 403
  * Atomarer Statusuebergang: UPDATE ... WHERE status=pending —
    konkurrierende Entscheidung -> 409
  * approver_id wird NIE ueberschrieben; resolved_by dokumentiert den
    Entscheider
- ApprovalDecisionError mit HTTP-Status-Codes; approve/reject-Routen
  fangen sie sauber ab (404/409/410/403 statt Flat-404)
- ApprovalResponse + Mapper um resolved_by ergaenzt

Abnahme (Astra): Falscher Entscheider, abgelaufene Anfrage und doppelte
Entscheidung werden abgewiesen — erfuellt (6 Tests).
Hinweis: workflows.py approve/reject-Aufrufer waren bereits kaputt
(F12, S2-Welle: approval[id] auf ORM-Objekt) und werden dort gefixt.

Tests: test_s1_security_guards.py 18/18 (6 neue F11-Tests). ruff clean.
Damit ist S1 — ALLE 11 Sicherheits-Findings der Astra-Welle 1 gefixt.
2026-09-18 08:53:54 +02:00

325 lines
10 KiB
Python

"""API routes for approval requests — /api/v1/approvals.
Endpoints: create, list (filterable), get detail, approve, reject, expire.
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.approval import (
ApprovalRequest,
create_approval_request,
expire_approval_request,
resolve_approval_request,
)
from app.core.db import get_db
from app.deps import get_current_user, require_permission
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/approvals", tags=["approvals"])
# ─── Schemas ───
class ApprovalCreate(BaseModel):
"""Create an approval request."""
entity_type: str = Field(..., min_length=1, max_length=80)
entity_id: str = Field(..., min_length=1)
action: str = Field(..., min_length=1, max_length=120)
requested_by: str | None = None
requested_by_type: str = Field("agent", pattern="^(user|agent|system)$")
approver_id: str | None = None
approver_group: str | None = Field(None, max_length=120)
expires_at: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ApprovalResolve(BaseModel):
"""Approve or reject an approval request."""
comment: str | None = Field(None, max_length=2000)
class ApprovalResponse(BaseModel):
"""Approval request response."""
id: str
tenant_id: str
entity_type: str
entity_id: str
action: str
requested_by: str
requested_by_type: str
approver_id: str | None = None
approver_group: str | None = None
resolved_by: str | None = None
status: str
comment: str | None = None
created_at: str | None = None
resolved_at: str | None = None
expires_at: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ApprovalListResponse(BaseModel):
"""Paginated approval request list."""
items: list[ApprovalResponse]
total: int
# ─── Helpers ───
def _to_response(r: ApprovalRequest) -> ApprovalResponse:
return ApprovalResponse(
id=str(r.id),
tenant_id=str(r.tenant_id),
entity_type=r.entity_type,
entity_id=str(r.entity_id),
action=r.action,
requested_by=str(r.requested_by),
requested_by_type=r.requested_by_type,
approver_id=str(r.approver_id) if r.approver_id else None,
approver_group=r.approver_group,
resolved_by=str(r.resolved_by) if r.resolved_by else None,
status=r.status,
comment=r.comment,
created_at=r.created_at.isoformat() if r.created_at else None,
resolved_at=r.resolved_at.isoformat() if r.resolved_at else None,
expires_at=r.expires_at.isoformat() if r.expires_at else None,
metadata=r.request_metadata or {},
)
def _parse_uuid(value: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(value)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail=f"Invalid {field}") from None
# ─── Endpoints ───
@router.post(
"",
dependencies=[Depends(require_permission("approvals:write"))],
response_model=ApprovalResponse,
status_code=201,
)
async def create_approval(
data: ApprovalCreate,
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Create a new approval request."""
tenant_id = uuid.UUID(current_user["tenant_id"])
entity_id = _parse_uuid(data.entity_id, "entity_id")
requested_by = (
_parse_uuid(data.requested_by, "requested_by")
if data.requested_by
else uuid.UUID(current_user["user_id"])
)
approver_id = _parse_uuid(data.approver_id, "approver_id") if data.approver_id else None
expires_at = None
if data.expires_at:
try:
expires_at = datetime.fromisoformat(data.expires_at.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(status_code=400, detail="Invalid expires_at") from None
req = await create_approval_request(
db,
tenant_id,
entity_type=data.entity_type,
entity_id=entity_id,
action=data.action,
requested_by=requested_by,
requested_by_type=data.requested_by_type,
approver_id=approver_id,
approver_group=data.approver_group,
expires_at=expires_at,
metadata=data.metadata,
)
await db.commit()
return _to_response(req)
@router.get(
"",
dependencies=[Depends(require_permission("approvals:read"))],
response_model=ApprovalListResponse,
)
async def list_approvals(
status: str | None = Query(None),
entity_type: str | None = Query(None),
entity_id: str | None = Query(None),
requested_by: str | None = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List approval requests with optional filters."""
tenant_id = uuid.UUID(current_user["tenant_id"])
query = select(ApprovalRequest).where(ApprovalRequest.tenant_id == tenant_id)
count_query = (
select(__import__("sqlalchemy").func.count())
.select_from(ApprovalRequest)
.where(ApprovalRequest.tenant_id == tenant_id)
)
if status:
query = query.where(ApprovalRequest.status == status)
count_query = count_query.where(ApprovalRequest.status == status)
if entity_type:
query = query.where(ApprovalRequest.entity_type == entity_type)
count_query = count_query.where(ApprovalRequest.entity_type == entity_type)
if entity_id:
eid = _parse_uuid(entity_id, "entity_id")
query = query.where(ApprovalRequest.entity_id == eid)
count_query = count_query.where(ApprovalRequest.entity_id == eid)
if requested_by:
rid = _parse_uuid(requested_by, "requested_by")
query = query.where(ApprovalRequest.requested_by == rid)
count_query = count_query.where(ApprovalRequest.requested_by == rid)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(
query.order_by(ApprovalRequest.created_at.desc()).limit(limit).offset(offset)
)
items = list(result.scalars().all())
return ApprovalListResponse(items=[_to_response(r) for r in items], total=total)
@router.get(
"/{request_id}",
dependencies=[Depends(require_permission("approvals:read"))],
response_model=ApprovalResponse,
)
async def get_approval(
request_id: str,
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get a single approval request by ID."""
tenant_id = uuid.UUID(current_user["tenant_id"])
rid = _parse_uuid(request_id, "request_id")
result = await db.execute(
select(ApprovalRequest).where(
ApprovalRequest.id == rid,
ApprovalRequest.tenant_id == tenant_id,
)
)
req = result.scalar_one_or_none()
if req is None:
raise HTTPException(status_code=404, detail="Approval request not found")
return _to_response(req)
@router.post(
"/{request_id}/approve",
dependencies=[Depends(require_permission("approvals:approve"))],
response_model=ApprovalResponse,
)
async def approve_approval(
request_id: str,
body: ApprovalResolve,
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Approve a pending approval request."""
tenant_id = uuid.UUID(current_user["tenant_id"])
rid = _parse_uuid(request_id, "request_id")
from app.core.approval import ApprovalDecisionError
try:
req = await resolve_approval_request(
db,
tenant_id,
rid,
decision="approved",
approver_id=uuid.UUID(current_user["user_id"]),
comment=body.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
if req is None:
raise HTTPException(status_code=404, detail="Approval request not found")
await db.commit()
return _to_response(req)
@router.post(
"/{request_id}/reject",
dependencies=[Depends(require_permission("approvals:approve"))],
response_model=ApprovalResponse,
)
async def reject_approval(
request_id: str,
body: ApprovalResolve,
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Reject a pending approval request."""
tenant_id = uuid.UUID(current_user["tenant_id"])
rid = _parse_uuid(request_id, "request_id")
from app.core.approval import ApprovalDecisionError
try:
req = await resolve_approval_request(
db,
tenant_id,
rid,
decision="rejected",
approver_id=uuid.UUID(current_user["user_id"]),
comment=body.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
if req is None:
raise HTTPException(status_code=404, detail="Approval request not found")
await db.commit()
return _to_response(req)
@router.post(
"/{request_id}/expire",
dependencies=[Depends(require_permission("approvals:write"))],
response_model=ApprovalResponse,
)
async def expire_approval(
request_id: str,
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Mark a pending approval request as expired (system only)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
rid = _parse_uuid(request_id, "request_id")
req = await expire_approval_request(db, tenant_id, rid)
if req is None:
raise HTTPException(status_code=404, detail="Approval request not found or not pending")
await db.commit()
return _to_response(req)