015b7e32f3
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.
325 lines
10 KiB
Python
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)
|