封存 (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>
478 lines
14 KiB
Python
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",
|
|
),
|
|
]
|