""" Multi-Sig Redis Service - 簽核狀態持久化 ========================================= Phase 6.1.1: Multi-Sig Redis 遷移 Features: - 簽核狀態 Redis Hash 持久化 - 7 天 TTL 稽核保留 (資安合規) - 分散式鎖防止 Race Condition - 與現有 SQLite 雙寫模式 (Phase 6.2 後可移除 SQLite) 統帥鐵律: - 所有簽核狀態變更必須經過此模組 - 7 天 TTL 不可修改 (資安稽核要求) - 分散式鎖必須包裹所有寫入操作 """ import json from datetime import datetime, timezone from uuid import UUID import structlog from src.core.redis_client import get_redis, RedisLock logger = structlog.get_logger(__name__) # ============================================================================= # Constants # ============================================================================= # Redis Key 前綴 APPROVAL_KEY_PREFIX = "approval:" SIGNATURE_KEY_PREFIX = "signature:" # 7 天 TTL (資安稽核要求) APPROVAL_TTL_SECONDS = 86400 * 7 # 604800 秒 # ============================================================================= # Approval State Model # ============================================================================= class ApprovalStateRedis: """ Redis 中的簽核狀態結構 Hash Fields: - id: 簽核單 ID - action: 操作類型 (DELETE_POD, RESTART_SERVICE, etc.) - description: 描述 - status: 狀態 (pending, approved, rejected, voided, executed) - risk_level: 風險等級 (critical, high, medium, low) - required_signatures: 需要簽核數 - current_signatures: 目前簽核數 - signatures: 簽核列表 (JSON Array) - created_at: 建立時間 - updated_at: 更新時間 - namespace: K8s Namespace - resource_name: 資源名稱 """ @staticmethod def get_key(approval_id: str | UUID) -> str: """取得 Redis Key""" return f"{APPROVAL_KEY_PREFIX}{str(approval_id)}" # ============================================================================= # Multi-Sig Redis Service # ============================================================================= class MultiSigRedisService: """ Multi-Sig Redis 持久化服務 提供簽核狀態的 CRUD 操作,包含: - 建立簽核單 - 新增簽名 - 更新狀態 - 查詢狀態 - 分散式鎖保護 """ async def create_approval( self, approval_id: str | UUID, action: str, description: str, risk_level: str, required_signatures: int, namespace: str = "default", resource_name: str = "", blast_radius: dict | None = None, dry_run_checks: list | None = None, ) -> dict: """ 建立新的簽核單 Args: approval_id: 簽核單 ID action: 操作類型 description: 描述 risk_level: 風險等級 required_signatures: 需要簽核數 namespace: K8s Namespace resource_name: 資源名稱 blast_radius: 爆炸半徑 dry_run_checks: Dry-Run 檢查結果 Returns: dict: 建立的簽核狀態 """ redis_client = get_redis() key = ApprovalStateRedis.get_key(approval_id) now = datetime.now(timezone.utc).isoformat() state = { "id": str(approval_id), "action": action, "description": description, "status": "pending", "risk_level": risk_level, "required_signatures": required_signatures, "current_signatures": 0, "signatures": json.dumps([]), # JSON Array "created_at": now, "updated_at": now, "namespace": namespace, "resource_name": resource_name, "blast_radius": json.dumps(blast_radius or {}), "dry_run_checks": json.dumps(dry_run_checks or []), } # 使用 HSET 寫入 Hash await redis_client.hset(key, mapping=state) # 設定 7 天 TTL (資安稽核要求) await redis_client.expire(key, APPROVAL_TTL_SECONDS) logger.info( "redis_approval_created", approval_id=str(approval_id), risk_level=risk_level, ttl_days=7, ) return state async def get_approval(self, approval_id: str | UUID) -> dict | None: """ 取得簽核狀態 Args: approval_id: 簽核單 ID Returns: dict | None: 簽核狀態,若不存在則返回 None """ redis_client = get_redis() key = ApprovalStateRedis.get_key(approval_id) state = await redis_client.hgetall(key) if not state: return None # 解析 JSON 欄位 if "signatures" in state: state["signatures"] = json.loads(state["signatures"]) if "blast_radius" in state: state["blast_radius"] = json.loads(state["blast_radius"]) if "dry_run_checks" in state: state["dry_run_checks"] = json.loads(state["dry_run_checks"]) # 轉換數值欄位 if "required_signatures" in state: state["required_signatures"] = int(state["required_signatures"]) if "current_signatures" in state: state["current_signatures"] = int(state["current_signatures"]) return state async def add_signature( self, approval_id: str | UUID, signer_id: str, signer_name: str, comment: str = "", source: str = "web", telegram_user_id: int | None = None, telegram_message_id: int | None = None, ) -> dict: """ 新增簽名 (含分散式鎖保護) 防禦場景: - Web + Telegram 同時簽核 - 防止 K8s Executor 被觸發兩次 Args: approval_id: 簽核單 ID signer_id: 簽核者 ID signer_name: 簽核者名稱 comment: 備註 source: 來源 (web, telegram, api) telegram_user_id: Telegram User ID telegram_message_id: Telegram Message ID Returns: dict: 更新後的簽核狀態 Raises: RuntimeError: 若無法取得鎖或簽核單不存在 """ redis_client = get_redis() key = ApprovalStateRedis.get_key(approval_id) lock_key = f"{str(approval_id)}:sign" # 使用分散式鎖保護簽核操作 async with RedisLock(lock_key, timeout=10, blocking_timeout=5): # 取得目前狀態 state = await self.get_approval(approval_id) if not state: raise RuntimeError(f"Approval not found: {approval_id}") # 檢查狀態是否可簽核 if state["status"] != "pending": raise RuntimeError(f"Approval is not pending: {state['status']}") # 檢查是否已簽過 signatures = state.get("signatures", []) for sig in signatures: if sig.get("signer_id") == signer_id: raise RuntimeError(f"Already signed by: {signer_id}") # 新增簽名 now = datetime.now(timezone.utc).isoformat() new_signature = { "signer_id": signer_id, "signer_name": signer_name, "timestamp": now, "comment": comment, "source": source, } if telegram_user_id: new_signature["telegram_user_id"] = telegram_user_id if telegram_message_id: new_signature["telegram_message_id"] = telegram_message_id signatures.append(new_signature) current_signatures = len(signatures) # 檢查是否達到簽核門檻 new_status = "pending" if current_signatures >= state["required_signatures"]: new_status = "approved" # 更新 Redis await redis_client.hset(key, mapping={ "signatures": json.dumps(signatures), "current_signatures": current_signatures, "status": new_status, "updated_at": now, }) # 延長 TTL (每次操作都重設 7 天) await redis_client.expire(key, APPROVAL_TTL_SECONDS) logger.info( "redis_signature_added", approval_id=str(approval_id), signer_id=signer_id, source=source, current=current_signatures, required=state["required_signatures"], new_status=new_status, ) return await self.get_approval(approval_id) async def update_status( self, approval_id: str | UUID, status: str, executor_id: str | None = None, execution_result: dict | None = None, ) -> dict: """ 更新簽核狀態 Args: approval_id: 簽核單 ID status: 新狀態 (approved, rejected, voided, executed) executor_id: 執行者 ID execution_result: 執行結果 Returns: dict: 更新後的簽核狀態 """ redis_client = get_redis() key = ApprovalStateRedis.get_key(approval_id) lock_key = f"{str(approval_id)}:status" async with RedisLock(lock_key, timeout=10, blocking_timeout=5): state = await self.get_approval(approval_id) if not state: raise RuntimeError(f"Approval not found: {approval_id}") now = datetime.now(timezone.utc).isoformat() updates = { "status": status, "updated_at": now, } if executor_id: updates["executor_id"] = executor_id if execution_result: updates["execution_result"] = json.dumps(execution_result) await redis_client.hset(key, mapping=updates) await redis_client.expire(key, APPROVAL_TTL_SECONDS) logger.info( "redis_status_updated", approval_id=str(approval_id), status=status, ) return await self.get_approval(approval_id) async def reject_approval( self, approval_id: str | UUID, rejector_id: str, rejector_name: str, reason: str = "", ) -> dict: """ 拒絕簽核單 Args: approval_id: 簽核單 ID rejector_id: 拒絕者 ID rejector_name: 拒絕者名稱 reason: 拒絕原因 Returns: dict: 更新後的簽核狀態 """ redis_client = get_redis() key = ApprovalStateRedis.get_key(approval_id) lock_key = f"{str(approval_id)}:reject" async with RedisLock(lock_key, timeout=10, blocking_timeout=5): state = await self.get_approval(approval_id) if not state: raise RuntimeError(f"Approval not found: {approval_id}") now = datetime.now(timezone.utc).isoformat() await redis_client.hset(key, mapping={ "status": "rejected", "updated_at": now, "rejector_id": rejector_id, "rejector_name": rejector_name, "rejection_reason": reason, }) await redis_client.expire(key, APPROVAL_TTL_SECONDS) logger.info( "redis_approval_rejected", approval_id=str(approval_id), rejector_id=rejector_id, ) return await self.get_approval(approval_id) async def list_pending(self, limit: int = 100) -> list[dict]: """ 列出所有待簽核單 注意: 此方法使用 SCAN,在大量資料時效能較低 建議在 Phase 6.2 加入索引機制 Args: limit: 最大返回數量 Returns: list[dict]: 待簽核單列表 """ redis_client = get_redis() results = [] async for key in redis_client.scan_iter(match=f"{APPROVAL_KEY_PREFIX}*", count=100): if len(results) >= limit: break state = await redis_client.hgetall(key) if state and state.get("status") == "pending": # 解析 JSON 欄位 if "signatures" in state: state["signatures"] = json.loads(state["signatures"]) if "required_signatures" in state: state["required_signatures"] = int(state["required_signatures"]) if "current_signatures" in state: state["current_signatures"] = int(state["current_signatures"]) results.append(state) return results async def exists(self, approval_id: str | UUID) -> bool: """ 檢查簽核單是否存在 Args: approval_id: 簽核單 ID Returns: bool: 是否存在 """ redis_client = get_redis() key = ApprovalStateRedis.get_key(approval_id) return await redis_client.exists(key) > 0 # ============================================================================= # Singleton # ============================================================================= _service: MultiSigRedisService | None = None def get_multi_sig_redis_service() -> MultiSigRedisService: """取得全域 MultiSigRedisService 實例""" global _service if _service is None: _service = MultiSigRedisService() return _service