Files
awoooi/apps/api/src/_archived/routes/approvals.py
OG T e0584bc181 refactor(api): Phase 16 R2 封存死代碼 + RiskLevel 統一
封存 (866 行):
- routes/approvals.py → _archived/routes/ (477 行,未註冊死代碼)
- services/approval.py → _archived/services/ (389 行,僅被死代碼使用)

合併 RiskLevel:
- models/approval.py 新增 HIGH (從 trust_engine.py 合併)
- trust_engine.py 改 import from models/approval.py
- 保留舊定義為註解供回滾

更新 services/__init__.py:
- 移除已封存模組的 import (註解保留回滾路徑)

驗證:
- RiskLevel 統一: models 與 trust_engine 使用同一 class
- 24 個 action_parsing 測試通過

回滾指令見 _archived/README.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-25 23:14:24 +08:00

478 lines
14 KiB
Python

"""
Approval (HITL) Endpoints
Phase 2.2: Dry-Run 預演 API
Phase 2.3: Multi-Sig 多重簽核 API
"""
from datetime import datetime, timedelta
from typing import Literal
from uuid import UUID, uuid4
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from src.services.approval import (
RISK_MATRIX,
ApprovalAlreadyDecidedError,
ApprovalNotFoundError,
DuplicateSignatureError,
InsufficientPermissionError,
TOCTOUConflictError,
multi_sig_engine,
)
from src.services.dry_run import dry_run_engine
router = APIRouter()
class PendingAction(BaseModel):
plugin_id: str
operation: str
parameters: dict
risk_level: Literal["low", "medium", "high", "critical"]
dry_run_result: dict | None = None
class Approval(BaseModel):
id: UUID
type: str
status: Literal["pending", "approved", "rejected", "expired"]
action: PendingAction
requested_at: datetime
expires_at: datetime
decided_at: datetime | None = None
decided_by: str | None = None
reason: str | None = None
class ApprovalDecision(BaseModel):
reason: str | None = None
modified_parameters: dict | None = None
class ApprovalList(BaseModel):
items: list[Approval]
next_page_token: str | None = None
# ==================== Dry-Run Models ====================
class DryRunCheckResponse(BaseModel):
"""單項檢查結果"""
name: str
passed: bool
message: str | None = None
class BlastRadiusResponse(BaseModel):
"""爆炸半徑"""
affected_pods: int
estimated_downtime: str
related_services: list[str]
data_impact: Literal["NONE", "READ_ONLY", "WRITE", "DESTRUCTIVE"]
class DryRunResponse(BaseModel):
"""Dry-Run 完整結果 (對應前端 ApprovalCard)"""
checks: list[DryRunCheckResponse]
blast_radius: BlastRadiusResponse
overall_passed: bool
risk_level: Literal["low", "medium", "high", "critical"]
# ==================== Multi-Sig Models (Phase 2.3) ====================
class SignatureRequest(BaseModel):
"""簽章請求"""
user_id: str
user_role: str # "admin", "devops", "cto", "ciso"
comment: str | None = None
class SignerInfo(BaseModel):
"""簽章者資訊"""
user_id: str
role: str
signed_at: datetime
class SignatureStatusResponse(BaseModel):
"""簽章狀態回應"""
approval_id: str
risk_level: str
status: str
current_signatures: int
required_signatures: int
has_required_role: bool
required_roles: list[str]
signers: list[SignerInfo]
class MultiSigApproveResponse(BaseModel):
"""Multi-Sig 簽核回應"""
approval_id: str
status: str
message: str
current_signatures: int
required_signatures: int
needs_more: bool
signers: list[SignerInfo]
class TOCTOUErrorResponse(BaseModel):
"""TOCTOU 衝突回應"""
error: str
reason: str
failed_checks: list[str]
signatures_cleared: bool
# In-memory storage
_approvals: dict[UUID, Approval] = {}
@router.get("", response_model=ApprovalList)
async def list_approvals(
status: Literal["pending", "approved", "rejected", "expired"] | None = None,
) -> ApprovalList:
"""列出待授權項目"""
items = list(_approvals.values())
if status:
items = [a for a in items if a.status == status]
return ApprovalList(items=items)
@router.get("/{approval_id}", response_model=Approval)
async def get_approval(approval_id: UUID) -> Approval:
"""取得授權項目詳情"""
if approval_id not in _approvals:
raise HTTPException(status_code=404, detail="Approval not found")
return _approvals[approval_id]
@router.post("/{approval_id}/approve", response_model=MultiSigApproveResponse)
async def approve_approval(
approval_id: UUID,
request: SignatureRequest,
) -> MultiSigApproveResponse:
"""
Multi-Sig 簽核 (Phase 2.3)
提交簽章到指定的審批項目。
根據風險等級,可能需要多個簽章才能完成審批。
風險矩陣:
- low: 自動執行
- medium: 需要 1 位 admin/devops
- high: 需要 2 位管理員
- critical: 需要 2 人,含 CTO 或 CISO
⚠️ TOCTOU 防護:
當簽章達到閾值時,會自動重新執行 Dry-Run。
如果資源狀態已改變,將回傳 409 Conflict 並清空所有簽章。
"""
# 確保 Approval 存在於舊系統
if approval_id not in _approvals:
raise HTTPException(status_code=404, detail="Approval not found")
approval = _approvals[approval_id]
# 同步到 Multi-Sig 引擎 (如果還沒有)
try:
multi_sig_engine.get_approval(approval_id)
except ApprovalNotFoundError:
multi_sig_engine.create_approval(
approval_id=approval_id,
operation=approval.action.operation,
parameters=approval.action.parameters,
risk_level=approval.action.risk_level,
)
# 執行簽核
try:
state = multi_sig_engine.approve_request(
approval_id=approval_id,
user_id=request.user_id,
user_role=request.user_role,
comment=request.comment,
)
# 同步狀態回舊系統
if state.status.value == "approved":
approval.status = "approved"
approval.decided_at = state.executed_at
requirement = RISK_MATRIX[state.risk_level]
return MultiSigApproveResponse(
approval_id=str(approval_id),
status=state.status.value,
message=(
"Approval complete - executing action"
if state.status.value == "approved"
else f"Signature recorded ({len(state.signatures)}/{requirement.min_signatures})"
),
current_signatures=len(state.signatures),
required_signatures=requirement.min_signatures,
needs_more=len(state.signatures) < requirement.min_signatures,
signers=[
SignerInfo(
user_id=sig.user_id,
role=sig.user_role.value,
signed_at=sig.signed_at,
)
for sig in state.signatures
],
)
except InsufficientPermissionError as e:
raise HTTPException(
status_code=403,
detail={
"error": "Insufficient permission",
"role": e.role,
"required_roles": e.required_roles,
},
) from e
except DuplicateSignatureError as e:
raise HTTPException(
status_code=409,
detail={
"error": "Duplicate signature",
"user_id": e.user_id,
},
) from e
except ApprovalAlreadyDecidedError as e:
raise HTTPException(
status_code=400,
detail={"error": str(e)},
) from e
except TOCTOUConflictError as e:
# ⚠️ TOCTOU 衝突 - 資源狀態已改變
raise HTTPException(
status_code=409,
detail={
"error": "TOCTOU Conflict",
"reason": e.reason,
"failed_checks": e.failed_checks,
"signatures_cleared": True,
},
) from e
@router.post("/{approval_id}/reject", response_model=Approval)
async def reject_approval(approval_id: UUID, decision: ApprovalDecision) -> Approval:
"""拒絕授權"""
if approval_id not in _approvals:
raise HTTPException(status_code=404, detail="Approval not found")
approval = _approvals[approval_id]
approval.status = "rejected"
approval.decided_at = datetime.utcnow()
approval.reason = decision.reason
# 同步到 Multi-Sig 引擎
try:
multi_sig_engine.reject_request(
approval_id=approval_id,
user_id="system",
user_role="admin",
reason=decision.reason,
)
except (ApprovalNotFoundError, ApprovalAlreadyDecidedError):
pass # 忽略,舊系統已處理
return approval
@router.get("/{approval_id}/signatures", response_model=SignatureStatusResponse)
async def get_signature_status(approval_id: UUID) -> SignatureStatusResponse:
"""
取得簽章狀態 (Phase 2.3)
回傳目前有多少簽章、還需要多少、已簽核者列表等資訊
"""
if approval_id not in _approvals:
raise HTTPException(status_code=404, detail="Approval not found")
approval = _approvals[approval_id]
# 確保同步到 Multi-Sig 引擎
try:
multi_sig_engine.get_approval(approval_id)
except ApprovalNotFoundError:
multi_sig_engine.create_approval(
approval_id=approval_id,
operation=approval.action.operation,
parameters=approval.action.parameters,
risk_level=approval.action.risk_level,
)
status = multi_sig_engine.get_signature_status(approval_id)
return SignatureStatusResponse(
approval_id=status["approval_id"],
risk_level=status["risk_level"],
status=status["status"],
current_signatures=status["current_signatures"],
required_signatures=status["required_signatures"],
has_required_role=status["has_required_role"],
required_roles=status["required_roles"],
signers=[
SignerInfo(
user_id=s["user_id"],
role=s["role"],
signed_at=datetime.fromisoformat(s["signed_at"]),
)
for s in status["signers"]
],
)
@router.get("/{approval_id}/dry-run", response_model=DryRunResponse)
async def run_dry_run(approval_id: UUID) -> DryRunResponse:
"""
執行 Dry-Run 預演檢查
Phase 2.2: 回傳 ApprovalCard 所需的 dryRunChecks 格式
- RBAC 權限檢查
- 語法正確性
- 資源存在性
- 爆炸半徑評估
"""
if approval_id not in _approvals:
raise HTTPException(status_code=404, detail="Approval not found")
approval = _approvals[approval_id]
action = approval.action
# 執行 Dry-Run 引擎
result = dry_run_engine.evaluate(
operation=action.operation,
parameters=action.parameters,
user_role="cluster-admin", # TODO: 從 JWT 取得真實角色
)
# 轉換為 API Response 格式
return DryRunResponse(
checks=[
DryRunCheckResponse(
name=c.name,
passed=c.passed,
message=c.message,
)
for c in result.checks
],
blast_radius=BlastRadiusResponse(
affected_pods=result.blast_radius.affected_pods,
estimated_downtime=result.blast_radius.estimated_downtime,
related_services=result.blast_radius.related_services,
data_impact=result.blast_radius.data_impact,
),
overall_passed=result.overall_passed,
risk_level=result.risk_level,
)
@router.post("/dry-run/preview", response_model=DryRunResponse)
async def preview_dry_run(
operation: str,
parameters: dict,
user_role: str = "cluster-admin",
) -> DryRunResponse:
"""
預覽 Dry-Run (不需要先建立 Approval)
用於前端即時預覽操作風險
"""
result = dry_run_engine.evaluate(
operation=operation,
parameters=parameters,
user_role=user_role,
)
return DryRunResponse(
checks=[
DryRunCheckResponse(
name=c.name,
passed=c.passed,
message=c.message,
)
for c in result.checks
],
blast_radius=BlastRadiusResponse(
affected_pods=result.blast_radius.affected_pods,
estimated_downtime=result.blast_radius.estimated_downtime,
related_services=result.blast_radius.related_services,
data_impact=result.blast_radius.data_impact,
),
overall_passed=result.overall_passed,
risk_level=result.risk_level,
)
# ==================== Test Helpers ====================
def create_test_approval(
operation: str = "delete_pod",
parameters: dict | None = None,
risk_level: Literal["low", "medium", "high", "critical"] = "high",
) -> Approval:
"""Create a test approval for development"""
approval_id = uuid4()
now = datetime.utcnow()
if parameters is None:
if operation == "delete_pod":
parameters = {"pod_name": "nginx-frontend-7d4b8c9f5-xk2m3"}
elif operation == "drop_table":
parameters = {"table_name": "user_sessions"}
else:
parameters = {}
approval = Approval(
id=approval_id,
type="action_execution",
status="pending",
action=PendingAction(
plugin_id="lewooogo-action-k8s",
operation=operation,
parameters=parameters,
risk_level=risk_level,
),
requested_at=now,
expires_at=now + timedelta(hours=1),
)
_approvals[approval_id] = approval
return approval
def create_test_approvals() -> list[Approval]:
"""建立多個測試 Approval (對應前端 Mock Data)"""
return [
# HIGH RISK: 刪除 Pod
create_test_approval(
operation="delete_pod",
parameters={"pod_name": "nginx-frontend-7d4b8c9f5-xk2m3"},
risk_level="high",
),
# CRITICAL: DROP TABLE (DESTRUCTIVE)
create_test_approval(
operation="drop_table",
parameters={"table_name": "user_sessions"},
risk_level="critical",
),
# MEDIUM: Scale Deployment
create_test_approval(
operation="scale_deployment",
parameters={"deployment": "api-server", "replicas": 5},
risk_level="medium",
),
]