""" Database-based Approval Service ================================ Phase 5: 永久記憶植入 將 TrustEngine 的 in-memory 邏輯轉換為資料庫 CRUD 操作。 重啟後資料完好無缺。 Features: - SQLAlchemy async CRUD - ApprovalRecord 持久化 - TimelineEvent 持久化 - 與原有 API 契約相容 """ from datetime import UTC, datetime, timedelta from typing import Any from uuid import UUID import structlog from sqlalchemy import and_, or_, select, update from src.core.trust_engine import classify_risk, get_required_signatures from src.db.base import get_db_context from src.db.models import ApprovalRecord, TimelineEvent from src.models.approval import ( ApprovalRequest, ApprovalRequestCreate, ApprovalStatus, BlastRadius, DataImpact, DryRunCheck, RiskLevel, Signature, ) logger = structlog.get_logger(__name__) # ============================================================================= # Conversion Helpers # ============================================================================= def approval_record_to_request(record: ApprovalRecord) -> ApprovalRequest: """ Convert DB ApprovalRecord to Pydantic ApprovalRequest 保持 API 契約相容性 """ # Parse blast_radius from JSON blast_radius = None if record.blast_radius: br = record.blast_radius blast_radius = BlastRadius( affected_pods=br.get("affected_pods", 0), estimated_downtime=br.get("estimated_downtime", "0"), related_services=br.get("related_services", []), data_impact=DataImpact(br.get("data_impact", "none").lower()) if br.get("data_impact") else DataImpact.NONE, ) # Parse dry_run_checks from JSON dry_run_checks = [] if record.dry_run_checks: for check in record.dry_run_checks: dry_run_checks.append( DryRunCheck( name=check.get("name", ""), passed=check.get("passed", True), message=check.get("message"), ) ) # Parse signatures from JSON signatures = [] if record.signatures: for sig in record.signatures: signatures.append( Signature( signer_id=sig.get("signer_id", ""), signer_name=sig.get("signer_name", ""), timestamp=datetime.fromisoformat(sig["timestamp"]) if sig.get("timestamp") else datetime.now(UTC), comment=sig.get("comment"), ) ) return ApprovalRequest( id=UUID(record.id), action=record.action, description=record.description, status=ApprovalStatus(record.status.value if hasattr(record.status, 'value') else record.status), risk_level=RiskLevel(record.risk_level.value if hasattr(record.risk_level, 'value') else record.risk_level), blast_radius=blast_radius, dry_run_checks=dry_run_checks, required_signatures=record.required_signatures, current_signatures=record.current_signatures, signatures=signatures, requested_by=record.requested_by, created_at=record.created_at, expires_at=record.expires_at, resolved_at=record.resolved_at, rejection_reason=record.rejection_reason, metadata=record.extra_metadata, # 戰略 B: 告警風暴收斂 fingerprint=record.fingerprint, hit_count=record.hit_count, last_seen_at=record.last_seen_at, ) def approval_request_to_record_data( request: ApprovalRequestCreate, risk_level: RiskLevel, required_sigs: int, fingerprint: str | None = None, # 戰略 B: 告警指紋 ) -> dict[str, Any]: """ Convert ApprovalRequestCreate to dict for ApprovalRecord creation """ blast_radius_dict = None if request.blast_radius: blast_radius_dict = { "affected_pods": request.blast_radius.affected_pods, "estimated_downtime": request.blast_radius.estimated_downtime, "related_services": request.blast_radius.related_services, "data_impact": request.blast_radius.data_impact.value.lower() if request.blast_radius.data_impact else "none", } dry_run_checks_list = [] if request.dry_run_checks: for check in request.dry_run_checks: dry_run_checks_list.append({ "name": check.name, "passed": check.passed, "message": check.message, }) now = datetime.now(UTC) return { "action": request.action, "description": request.description, "status": ApprovalStatus.APPROVED if risk_level == RiskLevel.LOW else ApprovalStatus.PENDING, "risk_level": risk_level, "required_signatures": required_sigs, "current_signatures": 0, "signatures": [], "blast_radius": blast_radius_dict or {}, "dry_run_checks": dry_run_checks_list, "requested_by": request.requested_by, "expires_at": request.expires_at, "extra_metadata": request.metadata, "resolved_at": now if risk_level == RiskLevel.LOW else None, # 戰略 B: 告警風暴收斂 "fingerprint": fingerprint, "hit_count": 1, "last_seen_at": now, } # ============================================================================= # Database Approval Service # ============================================================================= class ApprovalDBService: """ 資料庫授權服務 - 替代 in-memory TrustEngine 所有操作皆為資料庫 CRUD,重啟後資料保持 Phase 16 R3.4: 支援 Repository 注入 - 有注入 repository: 使用 Repository 層 - 無注入: 使用內嵌 DB 操作 (向下相容) """ def __init__(self, repository=None): """ 初始化 ApprovalDBService Args: repository: IApprovalRepository 實例 (可選,Phase 16 DI) """ self._repository = repository async def create_approval( self, request: ApprovalRequestCreate, ) -> ApprovalRequest: """ 建立新授權請求 (寫入資料庫) """ # 分類風險 risk_level = classify_risk( action=request.action, blast_radius=request.blast_radius, explicit_level=request.risk_level, ) # 取得所需簽核數 required_sigs = get_required_signatures(risk_level) # 準備資料 data = approval_request_to_record_data(request, risk_level, required_sigs) async with get_db_context() as db: record = ApprovalRecord(**data) db.add(record) await db.flush() await db.refresh(record) logger.info( "approval_created_db", id=record.id, risk_level=risk_level.value, status=record.status.value if hasattr(record.status, 'value') else record.status, ) return approval_record_to_request(record) # ========================================================================= # 戰略 B: 告警風暴收斂 # ========================================================================= async def create_approval_with_fingerprint( self, request: ApprovalRequestCreate, fingerprint: str, ) -> ApprovalRequest: """ 建立帶指紋的授權請求 (戰略 B) 用於告警收斂:相同指紋的告警會被聚合 """ risk_level = classify_risk( action=request.action, blast_radius=request.blast_radius, explicit_level=request.risk_level, ) required_sigs = get_required_signatures(risk_level) data = approval_request_to_record_data(request, risk_level, required_sigs, fingerprint=fingerprint) async with get_db_context() as db: record = ApprovalRecord(**data) db.add(record) await db.flush() await db.refresh(record) logger.info( "approval_created_with_fingerprint", id=record.id, fingerprint=fingerprint, risk_level=risk_level.value, ) return approval_record_to_request(record) async def find_by_fingerprint( self, fingerprint: str, debounce_minutes: int = 5, ) -> ApprovalRequest | None: """ 根據指紋查詢現有的告警記錄 (戰略 B) 查詢條件: 1. 相同指紋 2. 狀態為 PENDING,或 3. 在 debounce_minutes 分鐘內建立 Returns: ApprovalRequest if found, None otherwise """ now = datetime.now(UTC) cutoff_time = now - timedelta(minutes=debounce_minutes) async with get_db_context() as db: result = await db.execute( select(ApprovalRecord) .where(ApprovalRecord.fingerprint == fingerprint) .where( or_( ApprovalRecord.status == ApprovalStatus.PENDING, ApprovalRecord.created_at >= cutoff_time, ) ) .order_by(ApprovalRecord.created_at.desc()) .limit(1) ) record = result.scalar_one_or_none() if record: logger.info( "fingerprint_match_found", fingerprint=fingerprint, approval_id=record.id, hit_count=record.hit_count, status=record.status.value if hasattr(record.status, 'value') else record.status, ) return approval_record_to_request(record) return None async def increment_hit_count( self, approval_id: UUID, ) -> ApprovalRequest | None: """ 增加告警聚合次數 (戰略 B) 當相同指紋的告警再次觸發時: 1. hit_count += 1 2. last_seen_at = now 這樣可以跳過 LLM 分析,節省 API 成本! """ now = datetime.now(UTC) async with get_db_context() as db: # 更新 hit_count 和 last_seen_at result = await db.execute( update(ApprovalRecord) .where(ApprovalRecord.id == str(approval_id)) .values( hit_count=ApprovalRecord.hit_count + 1, last_seen_at=now, ) .returning(ApprovalRecord.hit_count) ) new_count = result.scalar_one_or_none() if new_count is None: return None # 重新讀取完整記錄 result = await db.execute( select(ApprovalRecord).where(ApprovalRecord.id == str(approval_id)) ) record = result.scalar_one_or_none() if record: logger.info( "hit_count_incremented", approval_id=str(approval_id), new_hit_count=new_count, last_seen_at=now.isoformat(), ) return approval_record_to_request(record) return None async def get_approval(self, approval_id: UUID) -> ApprovalRequest | None: """ 取得單一授權請求 Phase 16 R3.4: 支援 Repository 注入 """ # Phase 16: 使用 Repository (如果有注入) if self._repository: return await self._repository.get_by_id(approval_id) # Legacy: 內嵌 DB 操作 async with get_db_context() as db: result = await db.execute( select(ApprovalRecord).where(ApprovalRecord.id == str(approval_id)) ) record = result.scalar_one_or_none() if record is None: return None return approval_record_to_request(record) async def get_pending_approvals(self) -> list[ApprovalRequest]: """ 取得所有待簽核請求 Phase 16 R3.4: 支援 Repository 注入 """ # Phase 16: 使用 Repository (如果有注入) if self._repository: return await self._repository.get_pending() # Legacy: 內嵌 DB 操作 now = datetime.now(UTC) async with get_db_context() as db: # 先更新過期的請求 await db.execute( update(ApprovalRecord) .where(ApprovalRecord.status == ApprovalStatus.PENDING) .where(ApprovalRecord.expires_at < now) .values(status=ApprovalStatus.EXPIRED, resolved_at=now) ) # 取得所有 PENDING result = await db.execute( select(ApprovalRecord) .where(ApprovalRecord.status == ApprovalStatus.PENDING) .order_by(ApprovalRecord.created_at.desc()) ) records = result.scalars().all() return [approval_record_to_request(r) for r in records] async def sign_approval( self, approval_id: UUID, signer_id: str, signer_name: str, comment: str | None = None, ) -> tuple[ApprovalRequest | None, str, bool]: """ 簽核授權請求 Phase 5: 使用 FOR UPDATE 行鎖防止 Race Condition 當多人同時簽核時,確保只有一人能成功取得鎖並更新 Returns: (approval, message, execution_triggered) """ async with get_db_context() as db: # Phase 5: FOR UPDATE 行級鎖 - 防止併發簽核競爭 # SQLite 不支援 FOR UPDATE,但 PostgreSQL 完整支援 result = await db.execute( select(ApprovalRecord) .where(ApprovalRecord.id == str(approval_id)) .with_for_update() # Row-Level Lock ) record = result.scalar_one_or_none() logger.info( "sign_approval_lock_acquired", approval_id=str(approval_id), signer_id=signer_id, ) if record is None: return None, "Approval not found", False # 檢查狀態 status_value = record.status.value if hasattr(record.status, 'value') else record.status if status_value != "pending": return ( approval_record_to_request(record), f"Cannot sign: status is {status_value}", False, ) # 檢查是否已簽核 signatures = record.signatures or [] for sig in signatures: if sig.get("signer_id") == signer_id: return ( approval_record_to_request(record), f"User {signer_name} has already signed this approval", False, ) # Phase 5: 樂觀鎖 - 記錄更新前的簽名數 old_sig_count = record.current_signatures # 新增簽章 new_signature = { "signer_id": signer_id, "signer_name": signer_name, "timestamp": datetime.now(UTC).isoformat(), "comment": comment, } signatures.append(new_signature) new_sig_count = len(signatures) # 計算新狀態 execution_triggered = False new_status = record.status resolved_at = None if new_sig_count >= record.required_signatures: new_status = ApprovalStatus.APPROVED resolved_at = datetime.now(UTC) execution_triggered = True # Phase 5: 樂觀鎖更新 - 使用 WHERE current_signatures = old_value # 如果其他人已更新,這個 UPDATE 會更新 0 行 result = await db.execute( update(ApprovalRecord) .where(and_( ApprovalRecord.id == str(approval_id), ApprovalRecord.current_signatures == old_sig_count, # 樂觀鎖條件 )) .values( signatures=signatures, current_signatures=new_sig_count, status=new_status, resolved_at=resolved_at, ) ) # 檢查是否更新成功 if result.rowcount == 0: logger.warning( "sign_approval_optimistic_lock_conflict", approval_id=str(approval_id), signer_id=signer_id, old_sig_count=old_sig_count, ) return ( approval_record_to_request(record), "Concurrent modification detected. Please retry.", False, ) # 重新讀取更新後的記錄 result = await db.execute( select(ApprovalRecord).where(ApprovalRecord.id == str(approval_id)) ) record = result.scalar_one() if execution_triggered: message = f"Approval complete! ({new_sig_count}/{record.required_signatures} signatures)" else: message = f"Signature added ({new_sig_count}/{record.required_signatures})" logger.info( "approval_signed_db", id=record.id, signer=signer_name, current=record.current_signatures, required=record.required_signatures, execution_triggered=execution_triggered, ) return approval_record_to_request(record), message, execution_triggered async def reject_approval( self, approval_id: UUID, rejector_id: str, rejector_name: str, reason: str, ) -> tuple[ApprovalRequest | None, str]: """ 拒絕授權請求 """ async with get_db_context() as db: result = await db.execute( select(ApprovalRecord).where(ApprovalRecord.id == str(approval_id)) ) record = result.scalar_one_or_none() if record is None: return None, "Approval not found" status_value = record.status.value if hasattr(record.status, 'value') else record.status if status_value != "pending": return ( approval_record_to_request(record), f"Cannot reject: status is {status_value}", ) record.status = ApprovalStatus.REJECTED record.rejection_reason = f"{rejector_name}: {reason}" record.resolved_at = datetime.now(UTC) await db.flush() await db.refresh(record) logger.info( "approval_rejected_db", id=record.id, rejector=rejector_name, reason=reason, ) return approval_record_to_request(record), "Approval rejected" async def update_execution_status( self, approval_id: UUID, success: bool, ) -> None: """ 更新執行狀態 """ async with get_db_context() as db: status = ApprovalStatus.EXECUTION_SUCCESS if success else ApprovalStatus.EXECUTION_FAILED await db.execute( update(ApprovalRecord) .where(ApprovalRecord.id == str(approval_id)) .values(status=status) ) logger.info( "approval_execution_status_updated", id=str(approval_id), success=success, ) # ========================================================================= # Phase 6.4h: Proposals API 支援方法 # ========================================================================= async def get_approval_by_id(self, approval_id: UUID) -> ApprovalRequest | None: """ 根據 ID 取得單一授權請求 (Phase 6.4h) Args: approval_id: 授權請求 UUID Returns: ApprovalRequest if found, None otherwise """ async with get_db_context() as db: result = await db.execute( select(ApprovalRecord).where(ApprovalRecord.id == str(approval_id)) ) record = result.scalar_one_or_none() if record is None: return None return approval_record_to_request(record) async def get_all_approvals( self, status: ApprovalStatus | None = None, incident_id: str | None = None, limit: int = 50, offset: int = 0, ) -> list[ApprovalRequest]: """ 取得所有授權請求 (Phase 6.4h) Args: status: 狀態篩選 (可選) incident_id: Incident ID 篩選 (可選) limit: 每頁數量 offset: 偏移量 Returns: ApprovalRequest 清單 """ async with get_db_context() as db: query = select(ApprovalRecord) # 狀態篩選 if status is not None: query = query.where(ApprovalRecord.status == status) # Incident ID 篩選 (從 extra_metadata JSON 欄位) # NOTE: 這是基於 JSON 欄位查詢,效能可能受影響 # 若有效能問題,考慮新增 incident_id 欄位到 ApprovalRecord query = query.order_by(ApprovalRecord.created_at.desc()) query = query.offset(offset).limit(limit) result = await db.execute(query) records = result.scalars().all() approvals = [approval_record_to_request(r) for r in records] # 若有 incident_id 篩選,在應用層過濾 if incident_id: approvals = [ a for a in approvals if a.metadata and a.metadata.get("incident_id") == incident_id ] return approvals # ============================================================================= # Timeline Event Service # ============================================================================= class TimelineDBService: """ 時間軸事件服務 - Phase 4 Action Timeline 持久化 """ async def add_event( self, event_type: str, status: str, title: str, description: str | None = None, actor: str | None = None, actor_role: str | None = None, risk_level: str | None = None, approval_id: str | None = None, ) -> dict[str, Any]: """ 新增時間軸事件 """ async with get_db_context() as db: event = TimelineEvent( event_type=event_type, status=status, title=title, description=description, actor=actor, actor_role=actor_role, risk_level=risk_level, approval_id=approval_id, ) db.add(event) await db.flush() await db.refresh(event) logger.info( "timeline_event_added", id=event.id, type=event_type, title=title, ) return { "id": event.id, "type": event.event_type, "status": event.status, "title": event.title, "created_at": event.created_at.isoformat(), } async def get_events(self, limit: int = 50) -> list[dict[str, Any]]: """ 取得最近的時間軸事件 """ async with get_db_context() as db: result = await db.execute( select(TimelineEvent) .order_by(TimelineEvent.created_at.desc()) .limit(limit) ) events = result.scalars().all() return [ { "id": e.id, "type": e.event_type, "status": e.status, "title": e.title, "description": e.description, "actor": e.actor, "actor_role": e.actor_role, "risk_level": e.risk_level, "approval_id": e.approval_id, "created_at": e.created_at.isoformat(), } for e in events ] # ============================================================================= # Singleton Instances # ============================================================================= _approval_service: ApprovalDBService | None = None _timeline_service: TimelineDBService | None = None def get_approval_service(use_repository: bool = False) -> ApprovalDBService: """ 取得授權服務實例 Args: use_repository: 是否使用 Repository 層 (Phase 16 R3.4) Phase 16: 絞殺者模式 - use_repository=False: 使用內嵌 DB 操作 (預設,向下相容) - use_repository=True: 使用 ApprovalDBRepository """ global _approval_service if _approval_service is None: if use_repository: from src.repositories import get_approval_repository _approval_service = ApprovalDBService(repository=get_approval_repository()) logger.info("approval_service_with_repository") else: _approval_service = ApprovalDBService() return _approval_service def get_timeline_service() -> TimelineDBService: """取得時間軸服務實例""" global _timeline_service if _timeline_service is None: _timeline_service = TimelineDBService() return _timeline_service