Files
awoooi/apps/api/src/services/multi_sig_redis.py
OG T 7478dc0254 feat(phase6-9): Complete modular architecture and Agent Teams
Phase 6.4 - Modular Architecture:
- Add lewooogo-brain adapters for LLM providers
- Add lewooogo-data dual memory (Redis + PostgreSQL)
- Implement consensus engine for multi-agent decisions
- Add incident memory service for historical context

Phase 9 - Agent Teams (Claude Agent SDK):
- Add base agent class with Claude Sonnet 4 integration
- Implement action planner, blast radius, and security agents
- Add agent API endpoints and proposal workflow
- Integrate ADR-009 OpenClaw Agent Teams architecture

DevOps & CI/CD:
- Add GitHub Actions CI/CD workflows (ci.yaml, cd.yaml)
- Add pre-commit hooks and secrets baseline
- Add docker-compose for local development
- Update Kubernetes network policies

Frontend Improvements:
- Add auto-healing error boundary component
- Update i18n messages for agent features
- Enhance dual-state incident card with execution feedback

Documentation:
- Add 7 ADRs covering MCP, design system, architecture decisions
- Update ARCHITECTURE_MEMORY.md with modular design
- Add GLOBAL_RULES.md and SOUL.md for project identity

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-23 18:40:36 +08:00

443 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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