feat: add all application source code
- apps/api: FastAPI backend with Dockerfile - apps/web: Next.js frontend with Dockerfile - apps/sensor: Signal collection agent - packages: shared packages Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
443
apps/api/src/services/multi_sig_redis.py
Normal file
443
apps/api/src/services/multi_sig_redis.py
Normal file
@@ -0,0 +1,443 @@
|
||||
"""
|
||||
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 typing import Any
|
||||
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
|
||||
Reference in New Issue
Block a user